Git and GitHub Interview Questions and Answers: 60 Questions from Easy to Hard
This guide is for freshers and working developers in India who use Git every day and now have to explain it in an interview: campus hires, engineers at service companies moving to product teams, and leads who review pull requests. It has 60 questions in three levels. Easy covers the staging area, commits, branches, merges and remotes. Medium covers what goes wrong at work: rebasing, conflicts, reset versus revert, lost commits, rejected and forced pushes. Hard covers Git internals, branching strategies, protected branches, merge queues, large repositories and GitHub Actions security. Every command output shown comes from running Git 2.43 on small practice repositories. Run the commands in a scratch repository of your own, then take the mock test.
- 60 questions, 3 levels
- Updated
- CoreHRx Jobs
- 17,463 open IT jobs
What Git and GitHub interviews cover
- Daily commands: status, add, commit, log, diff, branch, switch, merge and push, and what each one does to the working tree, the staging area and the branch.
- Working with others: remotes, fetch versus pull, rejected pushes, pull requests, code review and keeping a fork in sync.
- Fixing mistakes: reset, revert, restore, stash, cherry-pick, the reflog, and when rewriting history is safe.
- Team process: branching strategies such as GitFlow and trunk-based development, protected branches, required checks and merge methods.
- For senior roles: the object model, rebase internals, large repositories, signed commits and GitHub Actions security.
How to prepare
- Freshers: create a practice repository and run every command in this guide yourself, checking git status after each step until you can predict its output.
- Make a bare repository with git init --bare and two clones of it on your laptop. That is enough to practise push, fetch, pull, rejected pushes and conflicts without any server.
- Experienced developers: be ready to describe a real incident, such as a bad merge you reverted or a lost commit you recovered, and what process change followed.
- Learn to read git log --oneline --graph output, because many interviewers draw a commit graph and ask what a command will do to it.
- Open one small pull request to an open-source project, or review a friend's, so you can talk about pull requests and code review from experience.
Common mistakes and interview-day tips
- Saying git pull and git fetch are the same. Fetch only updates remote-tracking branches; pull also merges or rebases into your branch.
- Recommending git reset --hard or a force push to undo a commit on a shared branch. On shared history, revert is the safe answer.
- Treating Git and GitHub as one thing. Git is the version control tool; GitHub hosts repositories and adds pull requests, reviews, Actions and access control.
- Answering only with a command. Also say what happens to the branch pointer, the staging area and the working tree, and who else is affected.
- When unsure, say how you would check safely: git status, git log --oneline --graph, or a quick test in a scratch clone.
Git and GitHub interview questions: Easy questions (freshers)
20 questions1. What is the difference between Git and GitHub?
Git is a distributed version control system: a program on your machine that records snapshots of your project, keeps the full history locally and lets you branch and merge without a network. GitHub is a hosting service built around Git. It keeps a copy of your repository online and adds collaboration features such as pull requests, code review, issues, GitHub Actions for CI, and access control. You can use Git without GitHub, for example with a bare repository on a company server, but GitHub always stores Git repositories underneath.
2. What are the working tree, the staging area and HEAD?
They are the three places a file can differ. The working tree is the files on disk that you edit. The staging area, also called the index, holds what will go into the next commit; git add copies a file there. HEAD points to the current branch and through it to the last commit. git status compares all three. In the short form the first column is staged versus HEAD and the second is working tree versus staged, so MM means app.py was staged and then edited again: a commit now would take only the staged version.
# app.py edited and staged, then edited again; notes.txt is new
$ git status --short
MM app.py
?? notes.txt
$ git status
On branch main
Changes to be committed:
...
Changes not staged for commit:
...
Untracked files:3. What is the difference between git diff and git diff --staged?
git diff with no arguments shows changes in the working tree that are not staged yet: the working tree against the index. git diff --staged, the same as --cached, shows what is staged: the index against the last commit, which is exactly what git commit would record. git diff HEAD shows both together. In the example the staged change is hi to hello and the unstaged change is hello to hello world. A common mistake is checking only git diff before committing and missing what is already staged.
$ git diff --staged # index vs last commit
...
-print("hi")
+print("hello")
$ git diff # working tree vs index
...
-print("hello")
+print("hello world")
$ git diff HEAD --stat # both together
app.py | 2 +-4. What does a Git commit actually store?
A commit is a small object that points to a full snapshot, not a list of changes. git cat-file -p shows its fields: a tree (the whole directory as it was), the parent commit (two or more for a merge, none for the first commit), the author and committer with timestamps, and the message. The commit id is a SHA-1 hash of all of this, so changing any field, even the date or the message, gives a new id. The diff you see in git log -p is worked out by comparing the commit's tree with its parent's tree.
$ git cat-file -p HEAD
tree 49a05ca53ffebe800f20e5469c4fb3b54cb6b146
parent 1568d618df34a321b202439765f965e4d26ebc42
author Priya Nair <priya@example.com> 1767587580 +0530
committer Priya Nair <priya@example.com> 1767587580 +0530
Update README
Explain how to run the service locally.5. How do you write a good commit message?
Write a short subject line, about 50 characters, in the imperative mood, such as Add GST rounding to invoice total, then a blank line, then a body that explains why the change was made and anything a reviewer must know. git log --oneline and most tools show only the subject, so make it specific. Passing -m twice gives the subject and the body as separate paragraphs. Keep each commit to one logical change so it can be reverted or cherry-picked alone. Messages like fix or changes are a common mistake: months later nobody knows what they did.
$ git commit -q -m "Add GST rounding to invoice total" -m "Round each line to 2 decimals before adding, as finance asked."
$ git log -1 --format=%B
Add GST rounding to invoice total
Round each line to 2 decimals before adding, as finance asked.
$ git log --oneline -1
6ceb7b5 Add GST rounding to invoice total6. What is a branch in Git, and why is creating one so cheap?
A branch is only a movable name that points to one commit. For a new branch it is a small file such as .git/refs/heads/feature/login holding a 40-character commit id, and HEAD records which branch you are on. Creating a branch writes that one reference, so it is instant whatever the size of the repository; no files are copied. When you commit, the current branch moves forward to the new commit. That is why teams can create a branch for every task and delete it after merging.
$ git switch -c feature/login
Switched to a new branch 'feature/login'
$ cat .git/HEAD
ref: refs/heads/feature/login
$ cat .git/refs/heads/feature/login
9dc049e592ccf2d13a4b5013d26f71205f069ca5
$ git rev-parse HEAD
9dc049e592ccf2d13a4b5013d26f71205f069ca57. What is a detached HEAD, and how do you keep work done in that state?
Normally HEAD points to a branch name. In detached HEAD state it points straight at a commit, which happens when you check out a commit id or a tag. You can look around and even commit, but no branch moves with you. When you switch away, Git warns that you are leaving commits behind, and they become hard to find. To keep them, create a branch while you are still there with git switch -c and a name, or afterwards from the id Git printed or from the reflog, for example git branch try-it HEAD@{1}.
$ git switch --detach HEAD~1
HEAD is now at a9eb534 one
$ cat .git/HEAD
a9eb534cb815ed5912b32ada1e6e7d7079434db9
$ git commit -q -am experiment
$ git switch main
Warning: you are leaving 1 commit behind, not connected to
any of your branches:
f451203 experiment
...
$ git branch try-it HEAD@{1}
$ git log --oneline -1 try-it
f451203 experiment8. Why were git switch and git restore added when git checkout already existed?
git checkout does two unrelated jobs, changing branches and overwriting files, and git checkout dev can mean either a branch or a file called dev, which has cost people their edits. Git 2.23 split the jobs. git switch changes branches, and git switch -c creates one. git restore restores files: from the index by default, or from any commit with --source. git restore --staged unstages a file and keeps your edits. checkout still works and is common in scripts, but switch and restore make the intent clear, and git status now suggests them in its hints.
$ git switch -c feature/upi
Switched to a new branch 'feature/upi'
$ git restore --staged app.py # unstage, keep the edit
$ git status --short
M app.py
$ git restore app.py # discard the edit
$ git restore --source=HEAD~1 app.py # take an older version
$ cat app.py
v1
# dev is a file here, so checkout quietly overwrites it
$ git checkout dev
Updated 1 path from the index9. What is the difference between a fast-forward merge and a merge commit?
If main has no new commits since the feature branch started, Git can fast-forward: it simply moves main up to the feature tip and makes no new commit. If both branches have new commits, Git does a three-way merge using their common ancestor and records a merge commit with two parents; in Git 2.43 the default strategy is called ort. Some teams use git merge --no-ff to always create a merge commit, so the history shows where a feature began and ended even when a fast-forward was possible.
$ git merge feature
Updating 74dd0b2..db09039
Fast-forward
# later, both branches have new commits
$ git merge --no-edit feature2
Merge made by the 'ort' strategy.
$ git log --oneline --graph
* c244791 Merge branch 'feature2'
|\
| * 7e34183 Add c
* | 5e36295 Add d
|/
* db09039 Add b
* 74dd0b2 base10. What is a merge conflict, and how do you resolve one?
A conflict happens when both branches changed the same lines, or one changed a file the other deleted, and Git cannot choose. The merge stops and the file gets conflict markers: your side between <<<<<<< HEAD and =======, the incoming side up to >>>>>>> feature. git status shows the file as unmerged, UU in the short form. Edit the file to the correct final content, remove the markers, git add the file and run git commit to finish. If you are not ready, git merge --abort puts everything back as it was before the merge.
$ git merge feature
Auto-merging config.txt
CONFLICT (content): Merge conflict in config.txt
Automatic merge failed; fix conflicts and then commit the result.
$ cat config.txt
<<<<<<< HEAD
timeout=45
=======
timeout=60
>>>>>>> feature
$ git status --short
UU config.txt
# edit config.txt to timeout=60, then
$ git add config.txt
$ git commit --no-edit11. How do .gitignore patterns work?
Each line is a pattern for untracked files Git should not offer to add. *.log matches a .log file in any folder. A trailing slash, as in node_modules/, matches only directories. A leading slash, as in /build, anchors the pattern to the folder of the .gitignore, so src/build is still shown. A line starting with ! re-includes a file an earlier pattern excluded, like keep.log here, and a line starting with a hash is a comment. When a file is ignored and you cannot see why, git check-ignore -v prints the file, line and pattern responsible.
$ cat .gitignore
node_modules/
*.log
!keep.log
/build
.env
#notes.txt
$ git status --short --untracked-files=all
?? .gitignore
?? keep.log
?? notes.txt
?? src/a.js
?? src/build/out.js
$ git check-ignore -v app.log build/out.js node_modules/x/i.js
.gitignore:2:*.log app.log
.gitignore:4:/build build/out.js
.gitignore:1:node_modules/ node_modules/x/i.js12. You added config.local to .gitignore, but Git still shows changes to it. Why, and how do you fix it?
.gitignore only affects untracked files. config.local was committed before the rule existed, so Git keeps tracking it and reports every change. Run git rm --cached config.local to remove it from the index while keeping your copy on disk, then commit; from then on the ignore rule applies. Warn the team first: when they pull that commit, Git deletes the file from their working tree. If the file held a password or key, removing it now does not remove it from history, so rotate that secret.
$ git status --short
M config.local
$ git rm --cached config.local
rm 'config.local'
$ git commit -q -m "Stop tracking config.local"
$ git status --short
$ git check-ignore -v config.local
.gitignore:1:config.local config.local
# in a teammate's clone after git pull
$ ls
app.py13. What is the difference between git fetch and git pull?
git fetch downloads new commits from the remote and updates remote-tracking branches such as origin/main, but leaves your own branch and files alone, so it is always safe. git pull runs a fetch and then brings origin/main into your current branch: a fast-forward when you have no local commits, otherwise a merge or a rebase, depending on your settings. Fetching first lets you inspect what changed, for example with git log main..origin/main, before deciding. After the fetch below, git status already knows main is one commit behind.
$ git fetch
...
74dd0b2..b6c5ed9 main -> origin/main
$ git status
On branch main
Your branch is behind 'origin/main' by 1 commit, and can be fast-forwarded.
$ git log --oneline main..origin/main
b6c5ed9 Rahul fix
$ git pull
Updating 74dd0b2..b6c5ed9
Fast-forward14. What does git clone set up, and what is origin?
git clone copies the whole repository with its history, adds a remote named origin pointing to the URL you cloned from, creates a remote-tracking branch such as origin/main and origin/dev for every branch on the server, and checks out only the default branch as a local branch. origin is just the default name; you can rename it or add more remotes. git switch dev, for a branch you do not have locally, creates it and sets it to track origin/dev, which is why git push and git pull then work with no arguments.
$ git branch -a
* main
remotes/origin/HEAD -> origin/main
remotes/origin/dev
remotes/origin/main
$ git switch dev
Switched to a new branch 'dev'
branch 'dev' set up to track 'origin/dev'.
# commit something, then
$ git push
...
dev -> dev15. What does git stash do, and when would you use it?
git stash saves your uncommitted changes to tracked files and returns the working tree to the last commit, so you can switch branches for an urgent fix. git stash list shows the entries, git stash pop reapplies the latest and removes it, and git stash apply reapplies but keeps it. Name entries with git stash push -m so you know which is which. By default new untracked files are not stashed, as new.txt shows; add -u to include them. Stashes stay in your local repository: a push or a clone does not carry them.
$ git stash
Saved working directory and index state WIP on main: c3bd227 Add app
$ git status --short
?? new.txt
$ git stash push -m "half-done GST report"
$ git stash list
stash@{0}: On main: half-done GST report
stash@{1}: WIP on main: c3bd227 Add app16. How do you unstage a file, and how do you throw away your local edits to it?
git restore --staged app.py takes the file out of the staging area but keeps your edits in the working tree; in the short status the M moves from the first column to the second. git restore app.py then overwrites the working copy with the version in the index, so the edit is gone. Be careful with that second command: changes that were never committed or stashed cannot be recovered by Git. Older tutorials use git reset HEAD app.py and git checkout -- app.py for the same two jobs.
$ git status --short
M app.py
$ git restore --staged app.py
$ git status --short
M app.py
$ git restore app.py
$ git status --short
$ cat app.py
v117. What is the difference between git reset, git revert and git restore?
They undo different things. git restore works on files: it discards edits in the working tree or unstages them, and never moves a branch. git reset moves the current branch pointer to another commit, and can also reset the index and working tree, so the later commits drop out of the branch; use it only on local work you have not pushed. git revert makes a new commit that applies the opposite of an earlier one, so nothing is rewritten. That makes revert the safe way to undo a commit that is already on a shared branch such as main.
18. What is the difference between a lightweight tag and an annotated tag?
A lightweight tag is only a name pointing at a commit, like a branch that never moves. An annotated tag, made with git tag -a, is a separate object that stores the tagger, date and message (and can be signed), and then points at the commit. git cat-file -t shows the difference: commit for the lightweight tag, tag for the annotated one. Use annotated tags for releases. git describe uses only annotated tags by default, which is why it ignored the nightly tag below until --tags was added.
$ git tag v1.0.0 # lightweight
# one more commit, then
$ git tag -a v1.1.0 -m "Release 1.1.0" # annotated
$ git cat-file -t v1.0.0
commit
$ git cat-file -t v1.1.0
tag
# one more commit, then a lightweight tag on it
$ git tag nightly
$ git describe
v1.1.0-1-gb941ceb
$ git describe --tags
nightly19. What is a pull request on GitHub?
A pull request asks to merge commits from one branch, the head (often in a fork), into another, the base (usually main). GitHub shows the diff, runs any checks such as GitHub Actions, and lets reviewers comment on lines, approve or request changes. New pushes to the head branch update the same pull request. Once it is approved and checks pass, it is merged with one of the methods the repository allows: a merge commit, squash and merge, or rebase and merge. A draft pull request marks work in progress and cannot be merged until it is marked ready.
20. What is the difference between a fork and a clone?
A clone is a full copy of a repository on your own machine, made with git clone. A fork is a copy of someone else's repository on GitHub, under your own account, made with the Fork button. You fork when you cannot push to the original, as with most open-source projects: you push branches to your fork and open a pull request back to the original, which is called upstream. In practice you do both. Fork on GitHub, clone your fork to your laptop, and add the original as a second remote named upstream.
Ready to check yourself? Take the free Git and GitHub mock test
Git and GitHub interview questions: Medium questions (1-3 years)
20 questions21. When would you use git rebase instead of git merge?
Both bring main's new commits into your feature branch. Merge keeps history exactly as it happened and adds a merge commit. Rebase replays your feature commits on top of the latest main as new commits with new ids, so history becomes a straight line and main can later fast-forward. Use rebase to tidy and update your own branch before opening or updating a pull request; use merge for shared branches and to record when a feature was integrated. Because rebase rewrites commits, never rebase a branch others have already built on.
$ git log --oneline -2
9f9c576 Add f2
b49055e Add f1
$ git rebase main
Successfully rebased and updated refs/heads/feature.
$ git log --oneline --graph
* a24768c Add f2
* 61d082b Add f1
* bd3db9f Main work
* 74dd0b2 base
$ git switch main
$ git merge feature
Updating bd3db9f..a24768c
Fast-forward22. How do you clean up small fix-up commits before opening a pull request?
Use fixup commits and an interactive rebase. When you correct an earlier commit, run git commit --fixup with that commit's id; Git names the new commit fixup! plus the original subject. Later, git rebase -i --autosquash main moves each fixup under its target and melds it in, so reviewers see clean, logical commits. In the todo list you can also change pick to reword, to edit a message, or to drop, to remove a commit. Do this only on your own branch before others build on it, then push with --force-with-lease.
$ git commit --fixup=HEAD~1
[feature a0f30b5] fixup! Add pay endpoint
$ git log --oneline main..
a0f30b5 fixup! Add pay endpoint
0933db9 Add tests
9285e63 Add pay endpoint
$ git rebase -i --autosquash main # todo list saved as shown
Successfully rebased and updated refs/heads/feature.
$ git log --oneline main..
593ad06 Add tests
d9ded00 Add pay endpoint23. Explain git reset --soft, --mixed and --hard.
All three move the current branch to the commit you name; they differ in what else they reset. --soft touches nothing else, so the undone commit's changes stay staged, ready to commit again, which is handy for combining commits. --mixed, the default, also resets the index, so the changes stay in your files but unstaged. --hard resets the index and the working tree too, throwing the changes away. After --hard the commit can still be found in the reflog, but uncommitted edits are lost. Use reset only on commits you have not pushed.
$ git reset --soft HEAD~1
$ git status --short
A b
# commit again, then
$ git reset HEAD~1 # same as --mixed
$ git status --short
?? b
# add and commit again, then
$ git reset --hard HEAD~1
HEAD is now at d582fb2 one
$ git status --short
$ ls
a24. How do you undo a merge that is already pushed to main?
Use git revert, not reset, because others already have the merge. A merge commit has two parents, so Git must be told which side is the mainline: git revert -m 1 with the merge id keeps parent 1, main as it was before the merge, and creates a new commit that removes everything the feature brought in. Without -m Git refuses, as shown. History stays intact for everyone and nothing needs a force push. Run the tests before pushing, because later commits may have built on the feature and the revert can conflict with them.
$ git revert HEAD
error: commit 894ccc4ac25864962385b6f62762a151fa0ad7a2 is a merge but no -m option was given.
fatal: revert failed
$ git revert -m 1 --no-edit HEAD
[main 87f9bff] Revert "Merge branch 'feature'"
$ ls -C
a m25. You ran git reset --hard HEAD~2 by mistake. How do you get the two commits back?
Use the reflog. Git records every move of HEAD and of each branch in a local log, so the old tip is still listed even though no branch points to it. git reflog shows HEAD@{1} as the commit before the reset; git reset --hard HEAD@{1} puts the branch back, or git branch rescue HEAD@{1} saves it on a new branch. By default reflog entries are kept for 90 days, or 30 days for commits no branch reaches any more. The reflog exists only in your own repository, so a fresh clone cannot help.
$ git reset --hard HEAD~2
HEAD is now at 359b42d Add f
$ git reflog
359b42d HEAD@{0}: reset: moving to HEAD~2
2d2059e HEAD@{1}: commit: Add invoice PDF
672a5dd HEAD@{2}: commit: Add GST calculation
359b42d HEAD@{3}: commit (initial): Add f
$ git reset --hard HEAD@{1}
HEAD is now at 2d2059e Add invoice PDF26. A bug fix landed on main, and release/2.4 needs it too. How do you bring it over?
Cherry-pick the fix onto the release branch: switch to release/2.4 and run git cherry-pick -x with the commit id. Git applies the same change as a new commit with a new id. The -x option adds a line naming the original commit, which helps anyone later tracing why the same fix appears twice. If the fix depends on earlier changes the release branch lacks, it can conflict; resolve it like a merge, git add the file and run git cherry-pick --continue, or git cherry-pick --abort to give up. Pick only the commits you need.
$ git switch release/2.4
$ git cherry-pick -x main~1
[release/2.4 cd0511f] Fix rounding in GST total
$ git log -1 --format=%B
Fix rounding in GST total
(cherry picked from commit d3e52b9242bbe88f6f5f9c5f8c256eb3b6ece5c4)27. During a rebase, what do --ours and --theirs mean?
They are the reverse of what most people expect. A rebase replays your commits onto the other branch, so while it runs, ours is the branch you are rebasing onto (here main) and theirs is your own commit being replayed. In the example, git checkout --ours gives main's rate=5 and --theirs gives the feature's rate=12. After fixing, git add the file and run git rebase --continue; git rebase --skip drops that commit, and git rebase --abort returns the branch to where it started. In a normal merge, ours is your current branch.
$ git rebase main
CONFLICT (content): Merge conflict in tax.cfg
error: could not apply 0f9d1eb... Feature rate
$ git checkout --ours tax.cfg
$ cat tax.cfg
rate=5
$ git checkout --theirs tax.cfg
$ cat tax.cfg
rate=12
$ git add tax.cfg
$ git rebase --continue28. git stash pop stopped with a conflict. What happened to your stash?
It is still there. When popping causes a conflict, Git applies what it can, marks the conflicted file as unmerged and keeps the entry, saying the stash entry is kept in case you need it again. Resolve the conflict and git add the file, check the result, then remove the entry yourself with git stash drop. To avoid the surprise, commit or stash before switching branches, and use git stash apply when you are unsure, because apply never drops the entry, whether or not it succeeds.
$ git stash pop
Auto-merging app.py
CONFLICT (content): Merge conflict in app.py
...
The stash entry is kept in case you need it again.
$ git stash list
stash@{0}: WIP on main: 733758d one
# resolve app.py and git add it, then
$ git stash drop29. Where should you ignore editor files like .idea/ or your own scratch files?
Not necessarily in the project .gitignore, which is shared through commits and should hold project rules such as build output and dependencies. For your own editor or OS files, use a global ignore file: Git reads ~/.config/git/ignore by default, or another file you name in core.excludesFile, which then replaces that default. For a file only you have in one repository, use .git/info/exclude, which is never committed. When something is ignored unexpectedly, git check-ignore -v shows which file and which line matched.
# .idea/ written to ~/.config/git/ignore and
# scratch.txt added to .git/info/exclude, then
$ git check-ignore -v .idea/ws.xml scratch.txt debug.log
.../.config/git/ignore:1:.idea/ .idea/ws.xml
.git/info/exclude:7:scratch.txt scratch.txt
.gitignore:1:*.log debug.log30. git pull fails with "Need to specify how to reconcile divergent branches". What does it mean?
Your main and origin/main both have commits the other lacks, and Git 2.43 will not guess whether to merge or rebase when pull.rebase and pull.ff are not set. Choose per command: git pull --rebase replays your commits on top for a linear history, git pull --no-rebase makes a merge commit, and git pull --ff-only fails unless no local commits exist. Or set a default, such as git config pull.rebase true. Many teams prefer rebase here, since it only rewrites your own unpushed commits. Run the tests before pushing.
$ git pull
...
hint: You have divergent branches and need to specify how to reconcile them.
...
fatal: Need to specify how to reconcile divergent branches.
$ git pull --ff-only
fatal: Not possible to fast-forward, aborting.
$ git pull --rebase
Successfully rebased and updated refs/heads/main.
$ git log --oneline --graph
* 2465776 Priya work
* bf8297b Rahul work
* 74dd0b2 base31. Your push is rejected with "fetch first". What do you do?
The remote branch has commits you do not have, usually because a teammate pushed first, and a normal push succeeds only when it is a fast-forward of the remote branch. Do not force push: that would delete their work. Run git pull --rebase (or fetch, then merge or rebase), resolve any conflicts, run the tests and push again. The rejection protects you, because Git refuses to throw away commits on the server. If it happens often on main, the team should move to short-lived branches and pull requests instead of pushing straight to main.
$ git push
...
! [rejected] main -> main (fetch first)
...
hint: Updates were rejected because the remote contains work that you do not
hint: have locally. This is usually caused by another repository pushing to
...
$ git pull --rebase
$ git push
...
bf8297b..2465776 main -> main32. Why use git push --force-with-lease instead of --force?
--force overwrites the remote branch whatever is there, so a teammate's newer commits silently disappear. --force-with-lease overwrites only if the remote branch is still where your remote-tracking branch says it was, so it fails with stale info when someone pushed in between. The catch is that any git fetch, including an automatic one by your IDE, moves origin/feature and the lease then passes, as the last push shows. Adding --force-if-includes, available since Git 2.30, also checks that you have actually integrated that remote tip.
# Rahul pushed a commit to feature after you amended yours
$ git push --force-with-lease
! [rejected] feature -> feature (stale info)
$ git fetch
...
feature -> origin/feature
$ git push --force-with-lease --force-if-includes
! [rejected] feature -> feature (remote ref updated since checkout)
$ git push --force-with-lease
+ 9811ff9...0d49ecb feature -> feature (forced update)
# Rahul's commit is now gone from the remote33. What does git commit --amend do, and when is it risky?
It replaces the last commit with a new one made from the old commit plus whatever is staged now, with a new message unless you pass --no-edit. Use it when you forgot a file or made a typo in the message. It is not an edit in place: the new commit has a different id, as the output shows, and the old one is left unreachable. If the old commit was already pushed, your branch and the remote now disagree and a normal push is rejected as non-fast-forward. On a shared branch, make a new commit instead.
$ git rev-parse --short HEAD
93d5f62
$ git add test.py
$ git commit --amend --no-edit
[main b751664] Add login
$ git rev-parse --short HEAD
b751664
# after pushing, amending again and pushing
$ git push
...
! [rejected] main -> main (non-fast-forward)34. You tagged a release, but the tag is not on GitHub. Why?
git push sends branches, not tags. Push one tag with git push origin v1.1.0, all local tags with git push origin --tags, or use git push --follow-tags, which pushes the branch plus annotated tags on the commits being pushed, but not lightweight ones. Setting push.followTags to true makes that the default. Avoid moving a tag that others have already fetched: a normal fetch will not update a tag they already have, and git fetch --tags refuses with would clobber existing tag, so their copies silently differ.
$ git push origin main
...
* [new branch] main -> main
$ git ls-remote --tags origin
# (no output: no tags on the remote yet)
$ git push origin v1.1.0
...
* [new tag] v1.1.0 -> v1.1.0
# new commit, annotated tag v1.2.0, then
$ git push --follow-tags
...
* [new tag] v1.2.0 -> v1.2.035. How does git bisect find the commit that introduced a bug?
It does a binary search over history. You mark one commit as bad, usually HEAD, and an older one as good. Git checks out the midpoint, you test it and mark it, and the range halves each time, so 1,000 commits need only about 10 steps. With git bisect run and a script that exits 0 for good and 1 for bad (125 means this commit cannot be tested, skip it), Git does the whole search alone. Here the 18 percent GST factor was mistyped as 1.8 in Change 6. Finish with git bisect reset to return to your branch.
$ cat ../check.sh
#!/bin/sh
grep -q 1.18 calc.txt
$ git bisect start HEAD HEAD~7
$ git bisect run ../check.sh
...
is the first bad commit
...
Change 6
$ git bisect reset
...
Switched to branch 'main'36. How do you see what a feature branch has that main does not, and why does git branch -d sometimes refuse?
git log --oneline main..feature/login lists the commits reachable from the feature branch but not from main, which is exactly what a merge would bring in. git branch -d deletes a branch only if it is fully merged into its upstream branch, or into HEAD when it has no upstream; otherwise it stops with not fully merged, so you do not lose work by accident. git branch -D forces the delete. Before forcing, check that log range, because afterwards those commits can be found only through the reflog.
$ git log --oneline main..feature/login
1eef6e3 two
$ git switch main
$ git branch -d feature/login
error: the branch 'feature/login' is not fully merged.
If you are sure you want to delete it, run 'git branch -D feature/login'
$ git branch -D feature/login
Deleted branch feature/login (was 1eef6e3).37. What are protected branches on GitHub, and which rules would you set on main?
A protected branch has rules that GitHub enforces on the server, so they apply to everyone, unlike local hooks. For main I would require a pull request before merging with at least one approval, dismiss stale approvals when new commits are pushed, require status checks such as tests and lint to pass, and require conversation resolution. GitHub blocks force pushes and deletion of a protected branch by default. Stricter repositories add required signed commits or linear history, and decide whether admins may bypass the rules. Rulesets are the newer way to apply such rules.
38. What makes a pull request easy to review, and how do you link it to an issue?
Keep it small and about one thing, with a title that says what changes and a description that says why, how it was tested, and anything risky such as a database migration. Add screenshots for UI changes. Open it as a draft while work continues and ask for review once checks pass. To link an issue, write a closing keyword such as Fixes #42 or Closes #42 in the description; GitHub closes the issue when the pull request is merged into the default branch. As a reviewer, comment on behaviour and risk, not only on style.
39. How do you keep your fork up to date with the original repository?
Add the original as a second remote, usually named upstream, fetch from it, and fast-forward your main: git fetch upstream, git switch main, git merge --ff-only upstream/main, then git push origin main to update your fork on GitHub. Keep your fork's main identical to upstream and do your own work on feature branches, so this update is always a clean fast-forward. GitHub also offers a Sync fork button on the fork's page for the same job. Then rebase your feature branch on the new main before updating your pull request.
$ git remote add upstream https://github.com/ORIGINAL-OWNER/REPO.git
$ git remote -v
origin ...fork.git (fetch)
origin ...fork.git (push)
upstream https://github.com/ORIGINAL-OWNER/REPO.git (fetch)
upstream https://github.com/ORIGINAL-OWNER/REPO.git (push)
$ git fetch upstream
...
* [new branch] main -> upstream/main
$ git switch main
$ git merge --ff-only upstream/main
Fast-forward
$ git push origin main40. What does a basic GitHub Actions workflow for pull requests look like?
A workflow is a YAML file in .github/workflows. The on key lists the events that start it, here pull requests to main and pushes to main. Jobs run in parallel by default, each on a fresh runner such as ubuntu-latest, and a job's steps run in order: actions/checkout fetches the repository, since the runner starts without your code, setup-node installs Node, then the commands run. If a step fails, the job fails, and if the job is a required status check, branch protection blocks the merge. Pipelines that deploy are a DevOps topic.
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm testReady to check yourself? Take the free Git and GitHub mock test
Git and GitHub interview questions: Hard questions (experienced)
20 questions41. Explain the Git object model: blobs, trees, commits and tags.
Git is a content-addressed store, and each object is named by the SHA-1 hash of its content. A blob holds a file's bytes with no name. A tree lists names, modes and ids of blobs and subtrees: one directory. A commit points to the root tree plus parents, author, committer and message. An annotated tag points to another object with a name and message. Identical content is stored once: README.md and src/copy.md share one blob, so the first commit has 5 objects. Editing one file adds a new blob and new trees on its path, and unchanged blobs are reused.
$ git cat-file -p 'HEAD^{tree}'
100644 blob ce013625030ba8dba906f756967f9e9ca394464a README.md
040000 tree c53bac753901001f5e94287d1097ca36cba2b654 src
$ git cat-file -p HEAD:src
100644 blob bafc5d9a1a836cea09dadcec13e66fe498cd997a app.py
100644 blob ce013625030ba8dba906f756967f9e9ca394464a copy.md
$ git count-objects
5 objects, 20 kilobytes
# edit src/app.py and commit: new blob, src tree, root tree, commit
$ git count-objects
9 objects, 36 kilobytes42. Why do rebased commits get new ids, and how does rebase know to skip a commit already on main?
A commit id hashes the tree, the parent id, author, committer with timestamps, and the message. Rebase replays each change onto a new parent, so even identical content gets a new id; that is why rebasing shared commits confuses everyone holding the old ids. To avoid duplicates, rebase compares patch ids, a hash of the diff itself. If main already has a commit with the same change, such as a cherry-picked fix, rebase drops it with a warning. git cherry shows this in advance: a minus sign means an equivalent change is already upstream.
$ git cherry -v main feature
- 44963a5ca639ffd105c2d129ab89f3515f32d091 Fix rounding
+ 664490c353a5a5abd359b97dd8a28e864917e53e Feature work
$ git rebase main
warning: skipped previously applied commit 44963a5
hint: use --reapply-cherry-picks to include skipped commits
...
$ git log --oneline main..feature
96bd11f Feature work43. feature-b was built on feature-a, and feature-a was squash-merged into main after review fixes. How do you move only feature-b's own commits?
Use git rebase --onto main feature-a feature-b. It takes the commits in feature-a..feature-b, here only B1, and replays them onto main. A plain git rebase main would also replay A1 and A2, because the squash commit on main has a different id and a different combined patch, so Git does not see them as applied; with the review fix in main, A1 now clashes with the reviewed file and you get a needless conflict. The same three-argument form moves a branch that was started from the wrong base, or cuts a range of commits out of a branch.
$ git log --oneline main..feature-b
...
$ git rebase --onto main feature-a feature-b
Successfully rebased and updated refs/heads/feature-b.
$ git log --oneline -3
...
# a copy of the old feature-b, rebased the plain way
$ git rebase main
...
CONFLICT (add/add): Merge conflict in a144. Compare GitFlow and trunk-based development. Which would you pick for a team that deploys several times a day?
GitFlow uses long-lived main and develop branches plus feature, release and hotfix branches. It suits software shipped in versions, such as an installed product or a mobile app with store releases, where older versions need fixes. In trunk-based development everyone merges small changes into main at least daily, hiding unfinished work behind feature flags, and main is always releasable. For a Bengaluru fintech deploying several times a day I would pick trunk-based: fewer long branches means fewer big merges and faster feedback, but it needs strong automated tests and required checks.
45. In GitFlow, how is a production hotfix handled, and why is it merged twice?
A hotfix branch starts from main, which holds the released code, not from develop, which already has unreleased work. After review it is merged into main with --no-ff and main is tagged with the new patch version, here v1.4.1. It is then merged into develop too; otherwise the next release, cut from develop, would bring the bug back. If a release branch is open at that moment, the fix goes into that release branch instead of develop. Then the hotfix branch is deleted. Forgetting the second merge is the classic GitFlow regression.
$ git switch -c hotfix/1.4.1 main
# commit the fix, then
$ git switch main
$ git merge --no-ff --no-edit hotfix/1.4.1
$ git tag -a v1.4.1 -m "1.4.1"
$ git switch develop
$ git merge --no-ff --no-edit hotfix/1.4.1
Merge made by the 'ort' strategy.
$ git branch -d hotfix/1.4.1
Deleted branch hotfix/1.4.1 (was 83344e1).
$ git describe main
v1.4.146. Merge commit, squash and merge, or rebase and merge: how do you choose on GitHub?
A merge commit keeps every commit of the branch and records the merge, so history is complete but busy, and one revert with -m 1 undoes the feature. Squash and merge turns the pull request into one commit on main: clean history and an easy revert, but the individual commits are lost and branches built on it need rebase --onto. Rebase and merge puts each commit on main with new ids and no merge commit, so every commit must build on its own. Pick one per repository, disable the rest in settings, and require linear history if you squash or rebase.
47. A developer pushed an API key to a public GitHub repository. What do you do?
First revoke or rotate the key at the provider, at once: it was compromised the moment it became public, and rewriting history cannot change that. Check the provider's logs for misuse. Removing it from history is a second step: rewrite with git filter-repo or BFG, since Git itself warns against filter-branch, force-push every branch and tag, and have everyone re-clone. Copies can survive in forks, clones and cached pull request views, so GitHub Support may be needed. Prevent repeats with secret scanning push protection and a pre-commit check.
48. What is git rerere, and when does it help?
rerere means reuse recorded resolution. With rerere.enabled set, Git records each conflicted hunk and how you resolved it, and when the same conflict appears again it applies your earlier resolution. That helps when you rebase a long-running branch again and again, or test-merge a branch, throw the merge away and redo it later. Git still stops so you can review: the file stays unmerged unless rerere.autoUpdate is also set, which stages it. Because it replays a wrong resolution just as faithfully, check the diff before committing.
$ git config rerere.enabled true
$ git merge feature
CONFLICT (content): Merge conflict in c.cfg
Recorded preimage for 'c.cfg'
# resolve to limit=20 and commit; later undo and redo the merge
$ git reset --hard HEAD~1
$ git merge feature
CONFLICT (content): Merge conflict in c.cfg
Resolved 'c.cfg' using previous resolution.
$ cat c.cfg
limit=20
$ git status --short
UU c.cfg49. What is the difference between git merge -X ours and git merge -s ours?
They sound alike but do very different things. -X ours is an option to the normal ort strategy: the merge runs as usual and only conflicting hunks are settled in favour of your side, so non-conflicting changes from the other branch, like the new first line and new.txt here, still come in. -s ours is a separate strategy that ignores the other branch completely: the result is exactly your current tree, and only a merge commit is recorded. -s ours is meant for marking an old branch as merged without its content; used by mistake, it silently discards work.
$ git merge -X ours --no-edit feature
$ cat f.txt
A
b
c
d
e
F-ours
$ ls -C
f.txt new.txt
# start again from the same main
$ git merge -s ours --no-edit feature
Merge made by the 'ours' strategy.
$ ls -C
f.txt50. After a merge was reverted on main, the fixed branch is merged again, but the old changes do not come back. Why?
Reverting a merge undoes its changes but not its history: the feature commits are still ancestors of main. When you merge the branch again, Git brings in only commits main does not already have, so only the new fix arrives and the reverted work stays missing, here file f. The fix is to revert the revert commit, which reapplies the original changes; in Git 2.43 that commit is titled Reapply plus the original subject. Explain this in the pull request, because a clean merge that silently lacks code is easy to miss in review.
$ git revert -m 1 --no-edit HEAD # undo the merge
$ ls -C
a m
# feature gets a fix commit and is merged again
$ git merge --no-edit feature
$ ls -C
a g m
$ git revert --no-edit HEAD~1 # revert the revert
[main 9513294] Reapply "Merge branch 'feature'"
$ ls -C
a f g m51. How do you make Git fast on a very large repository or in CI?
Download less. A shallow clone, git clone --depth 1, fetches only the latest commit, ideal for CI builds, but tools that need history break, as git describe shows. A blobless partial clone, --filter=blob:none, fetches every commit and tree but file contents only when needed: here 4 old versions were missing and one more arrived on demand. GitHub supports partial clone. Sparse-checkout limits the working tree to the folders you need, useful in a monorepo. For a local path, use a file:// URL, because --depth is ignored in local clones.
$ git clone --depth 1 big shallow-local
warning: --depth is ignored in local clones; use file:// instead.
$ git clone --depth 1 "file://$PWD/big" shallow
$ git -C shallow describe
fatal: No names found, cannot describe anything.
$ git clone --filter=blob:none "file://$PWD/big" partial
# inside partial: missing counts objects not downloaded yet
$ missing
4
$ git show HEAD~4:web/a.js
v1
$ missing
352. How do signed commits work, and how does GitHub enforce them?
A signed commit carries a signature from the committer's GPG, SSH or S/MIME key, which proves who made it, since anyone can set any name and email in user.name and user.email. Since Git 2.34 you can sign with an SSH key: set gpg.format to ssh and user.signingkey to the key, and commit with -S or set commit.gpgsign. GitHub marks a commit Verified when the signature matches a signing key on the committer's account. A protected branch or ruleset can require signed commits, and vigilant mode shows unsigned commits made under your name as Unverified.
$ git config gpg.format ssh
$ git config user.signingkey ~/.ssh/id_ed25519.pub
$ git config commit.gpgsign true
$ git config gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers
$ git commit -q -m "Signed commit"
$ git verify-commit HEAD
Good "git" signature for priya@example.com with ED25519 key SHA256:...
$ git log -1 --format='%G? %s'
G Signed commit53. What is a CODEOWNERS file, and how does it work with branch protection?
CODEOWNERS maps file patterns to the people or teams responsible for them. GitHub reads it from .github/, the repository root or docs/ on the base branch, and requests the owners as reviewers when a pull request that is not a draft touches their files. Patterns follow gitignore-like rules, and the last matching line wins, so put broad rules first and specific ones later. On its own it only requests reviews; to make owner approval mandatory, turn on Require review from Code Owners in branch protection or a ruleset. Owners need write access.
# .github/CODEOWNERS
* @example-org/platform
/payments/ @example-org/payments-team
/db/migrations/ @example-org/dba
*.tf @example-org/devops54. What problem does a GitHub merge queue solve?
On a busy main, two pull requests can each pass checks against an older main and still break main together once both are merged. Requiring branches to be up to date prevents that, but forces authors to update and re-run CI again and again. A merge queue does this for them: each queued pull request is tested in a temporary branch that contains the latest main plus the pull requests ahead of it, and it is merged only if the required checks pass there. CI workflows must also trigger on the merge_group event, or the required checks never report.
55. How are secrets protected when a pull request comes from a fork, and why is pull_request_target risky?
For the pull_request event from a fork, GitHub passes no secrets to the runner except GITHUB_TOKEN, and that token is read-only, so a contributor cannot steal keys by editing the workflow or the tests. pull_request_target is different: it runs in the context of the base repository, with secrets and a token that can write, using the workflow from the base branch. That is fine for labelling or commenting, but if it checks out and builds the pull request's code, untrusted code runs with your secrets. Also require approval before workflows from new contributors run.
on: pull_request_target
permissions:
contents: read
pull-requests: write
jobs:
label:
runs-on: ubuntu-latest
steps:
# labels the PR; never checks out or runs its code
- uses: actions/labeler@v556. Windows and Linux developers keep producing whole-file diffs because of line endings. How do you fix it for the team?
Commit a .gitattributes file instead of relying on each person's core.autocrlf. The line * text=auto stores text files with LF inside Git and converts on checkout as the platform needs; eol=lf keeps shell scripts LF on disk too, since CRLF breaks them; binary means never convert or diff. git ls-files --eol shows both sides: win.txt is LF in the index while the file on disk still has CRLF. Files committed before the rule stay CRLF inside Git until you run git add --renormalize . once and commit.
$ cat .gitattributes
* text=auto
*.sh text eol=lf
*.png binary
$ git ls-files --eol
i/crlf w/crlf attr/text=auto old.txt
i/lf w/lf attr/text eol=lf run.sh
i/lf w/crlf attr/text=auto win.txt
$ git add --renormalize .
$ git ls-files --eol old.txt
i/lf w/crlf attr/text=auto old.txt57. How do you find the commit that removed a setting when git blame no longer shows the line?
git blame only explains lines that exist now. To search history for a string, use the pickaxe: git log -S MAX_RETRIES lists commits where the number of occurrences of that string changed, so it finds the commit that added it and the one that removed it. git log -G takes a regular expression and matches any added or removed line containing it, so it also finds the commit that changed the value from 3 to 5. Add -p to see the diffs. To blame a deleted line, run git blame on the parent of the commit that removed it.
$ git log -S MAX_RETRIES --oneline
b7642a9 Replace retries with timeout
68a1c52 Add retry setting
$ git log -G MAX_RETRIES --oneline
b7642a9 Replace retries with timeout
3c428de Raise retries
68a1c52 Add retry setting
$ git blame -L 1,1 b7642a9^ -- settings.py
3c428ded (Priya Nair 2026-01-05 10:03:00 +0530 1) MAX_RETRIES = 558. When does Git actually delete a commit that no branch points to?
Only when no ref and no reflog entry reaches it, and it is old enough. A commit dropped by reset is still in the reflog, so git fsck does not even list it as unreachable until you pass --no-reflogs. git gc, which also runs automatically from time to time, expires reflog entries, by default after 90 days or 30 days for unreachable commits, and prunes loose unreachable objects older than two weeks. Forcing the expiry and running git gc --prune=now deletes it for real. So recovery is possible for weeks, but only in the repository where it happened.
$ git reset -q --hard HEAD~1
$ git fsck --unreachable
$ git fsck --unreachable --no-reflogs
unreachable commit 06a3243ae744b3af34e04482874b19b3f46e7cad
...
$ git reflog expire --expire-unreachable=now --all
$ git gc -q --prune=now
$ git cat-file -t 06a3243ae744b3af34e04482874b19b3f46e7cad
fatal: git cat-file: could not get object info59. Can you enforce team rules with Git hooks, and what are their limits?
Client-side hooks are scripts in .git/hooks that Git runs at points such as pre-commit; a non-zero exit stops the commit, as this secret check shows. They give fast feedback, but they are not enforcement. git clone does not copy them, so each developer must install them (core.hooksPath pointing to a committed folder helps), and anyone can skip them with --no-verify. Real enforcement must run on the server: on GitHub that means required status checks, protected branches or rulesets, and secret scanning push protection.
$ cat .git/hooks/pre-commit
#!/bin/sh
if git diff --cached | grep -q 'AKIA'; then
echo "pre-commit: looks like an AWS key, commit blocked"
exit 1
fi
$ git commit -m "Add cfg"
pre-commit: looks like an AWS key, commit blocked
$ git commit --no-verify -m "Add cfg"
[main (root-commit) dc331ac] Add cfg60. A teammate force-pushed main and three commits disappeared from GitHub. How do you recover them?
Any clone that fetched before the force push still has them. A fetch afterwards prints forced update, and the reflog of the remote-tracking branch still holds the old tip as origin/main@{1}. Check that commit, then push it back with a lease so nothing newer is overwritten: git push --force-with-lease=main:origin/main origin, then the old id followed by :main. If the teammate's new commit should stay, cherry-pick it on top afterwards. Then protect main so force pushes are blocked and this cannot happen again.
$ git fetch
...
+ 77844ae...6f5220f main -> origin/main (forced update)
$ git reflog show origin/main
6f5220f refs/remotes/origin/main@{0}: fetch: forced-update
77844ae refs/remotes/origin/main@{1}: update by push
$ git log --oneline -1 77844ae
77844ae Commit 4
$ git push --force-with-lease=main:origin/main origin 77844ae:main
...
+ 6f5220f...77844ae 77844ae -> main (forced update)Practice questions for November 2026
New every month3 questions from the Git and GitHub mock test, the way they appear in it. A new set comes on the 1st of every month. Pick your answer, then open it.
-
Which command tells you which .gitignore line is ignoring app.log?
- git ls-files --others app.log
- git check-ignore -v app.log
- git log -- app.log
- git status --ignored app.log
Show the answer
Answer: git check-ignore -v app.log. git check-ignore -v prints the source file, line number and pattern that matched, such as .gitignore:1:*.log, which is the quickest way to debug a surprising ignore. status --ignored lists ignored files without the rule.
-
Ankita runs git reset --hard HEAD~1 notes.txt to get the old file back. What happens?
- The whole branch moves back one commit and notes.txt is deleted
- Git refuses: a hard reset cannot be limited to paths; use git restore
- notes.txt is reset and the branch also moves back one commit
- notes.txt is reset to HEAD~1 while the other files stay as they are
Show the answer
Answer: Git refuses: a hard reset cannot be limited to paths; use git restore. A reset with paths updates only the index, so --hard (and --soft) are not allowed with paths. To bring back an old file use git restore --source=HEAD~1 --staged --worktree notes.txt, or git checkout HEAD~1 -- notes.txt.
-
What does git clone --filter=blob:none do with a server that supports it?
- Downloads only the latest commit, exactly like --depth 1, with no history
- Downloads all commits and trees, and fetches file contents only when needed
- Skips binary files for good, so they never appear in the checkout
- Clones the history without any files, so no checkout is possible at all
Show the answer
Answer: Downloads all commits and trees, and fetches file contents only when needed. A blobless partial clone has the full commit history for git log, but blobs arrive on demand, for example during checkout or git blame. It suits large repos with many old files; --depth instead cuts the history.
Ready to apply?
There are 17,463 open IT jobs on CoreHRx right now. Create a free profile once and apply with it.