Git — Cheat Sheet
Interview Q&A · 100 topics. Download the PDF or the Instagram carousel and share it.
What are the four Git object types and how do they fit together?
A blob stores file contents — just bytes, with no name and no metadata. A tree represents a directory: a list of entries, each with a name, a mode, and a pointer to a blob or another tree. Filenames live in trees, not in blobs, which is why renaming a file creates a new tree but reuses the blob. A commit points to one tree — a complete snapshot of the working directory — plus zero or more parents, an author, a committer and a message. A tag object is an annotated tag: a named pointer with its own message and author. Every object is content-addressed: its name is the SHA-1 (now migrating to SHA-256) of its contents. That is why identical content is stored once no matter how many places it appears, and why any change anywhere produces a completely different hash. The consequence worth stating: a commit hash covers the entire history, because it includes the tree hash and the parent hashes, which include theirs. Changing anything in the past changes every hash after it — which is exactly why rewriting history creates new commits rather than editing old ones.
What is a branch in Git, really?
A file containing a 40-character commit hash. That is all. A branch is a movable pointer to a commit, stored in .git/refs/heads/. Creating a branch writes one small file; deleting it removes that file. This is why branching in Git is instantaneous and why people branch freely, in contrast to systems where a branch means copying a tree. HEAD is a pointer to the current branch — usually a symbolic ref containing something like "ref: refs/heads/main". When you commit, Git creates the commit object and then updates the branch file that HEAD points at. Several confusing behaviours follow directly from this model. Detached HEAD is simply HEAD pointing at a commit hash rather than at a branch. Commits still work, but no branch pointer moves, so those commits become unreachable when you leave. Deleting a branch does not delete commits — it removes a pointer. The commits survive until garbage collection, which is why deleted work is usually recoverable through the reflog. And fast-forward merging is just moving a pointer forward, since no new commit is needed.
What are the three trees — working directory, index, and HEAD?
HEAD is the snapshot of the last commit — what the repository looked like when you committed. The index, or staging area, is a proposed next commit. It is a real file holding a list of paths with their blob hashes and metadata. The working directory is the actual files on disk. Every core command moves data between these three. git add copies from working directory to index. git commit writes the index into a new commit and moves the branch. git checkout of a path copies from index to working directory. git reset moves HEAD and, depending on the flag, the index and working directory too. Understanding this explains git reset's three modes precisely. --soft moves HEAD only, leaving the index and working directory — so the changes appear staged. --mixed, the default, moves HEAD and resets the index, so changes appear unstaged. --hard moves all three, discarding the changes entirely. It also explains why staging a file and then editing it means the commit contains the earlier version — the index holds a snapshot taken at the moment you added it, not a reference to the file.
Why does Git store snapshots rather than diffs?
Each commit points to a tree representing the complete state of the project at that moment, not a delta from the previous commit. The advantage is that checking out any commit is a direct operation — read the tree and write the files — rather than replaying a chain of patches from the beginning. That makes operations on old history fast regardless of depth, which matters for branching, merging and blame. It sounds wasteful, but content addressing means unchanged files are not duplicated. If a file did not change between two commits, both trees point at the same blob. A commit touching one file in a repository of ten thousand adds one blob and the trees along its path — everything else is shared. Git does use deltas, but at a different layer: packfiles compress objects against each other for storage and transfer. That is a storage optimisation beneath the object model rather than part of it. The practical consequence is that Git can answer "what did this project look like at that commit?" instantly, and that merges work on trees rather than on patch sequences — which is why Git handles branching and merging so much better than the diff-based systems it replaced.
What is the difference between git fetch, pull, and clone?
git fetch downloads new objects and updates remote-tracking branches such as origin/main. It does not touch your working directory or your local branches, so it is always safe — you can inspect what changed before deciding what to do. git pull is fetch followed by an integration step. By default that is a merge, producing a merge commit when both sides have diverged. With --rebase, or with pull.rebase configured, it replays your local commits on top instead. git clone creates a new repository: it initialises, adds the remote, fetches everything, and checks out the default branch. The practical advice is to prefer fetch and then look, particularly on a shared branch. A plain pull can produce a surprise merge commit, and pull --rebase can start a conflict resolution you were not expecting in the middle of something else. The distinction people miss is that origin/main is not a live view of the remote — it is a local cache updated only by fetch. If it looks stale, that is because nothing has fetched. Comparing your branch against origin/main without fetching first compares against whatever you last downloaded.
What is HEAD and what does detached HEAD mean?
HEAD is a reference to the current position — normally a symbolic reference pointing at a branch, which in turn points at a commit. Detached HEAD means HEAD points directly at a commit rather than at a branch. You get it by checking out a commit hash, a tag, or a remote-tracking branch. In that state everything works: you can inspect files, run builds, and even commit. The problem is that commits you create update HEAD but no branch, so when you check out a branch again those commits have nothing pointing at them. They become unreachable and are eventually garbage collected. Git warns about this, and the warning is worth reading rather than dismissing. Recovery is straightforward if you notice: git reflog shows where HEAD has been, so you can find the commit and create a branch at it. That is the standard fix for "I did work and then lost it". Detached HEAD is legitimate and useful for inspecting an old state, bisecting, or building a specific tag. The rule is simply that if you intend to keep commits, create a branch first — or create one afterwards from the reflog.
What is the difference between a lightweight and an annotated tag?
A lightweight tag is a file containing a commit hash — the same as a branch, except it is not meant to move. An annotated tag is a real object in the database, with its own hash, containing the target, a tagger, a date, a message, and optionally a GPG signature. The practical differences matter for releases. An annotated tag records who created it and when, which a lightweight tag does not. It can be signed, which is how you prove a release came from who it claims. git describe uses annotated tags by default. And pushing tags behaves more predictably with annotated ones. So the guidance is annotated tags for anything meaningful — releases, milestones — and lightweight tags only as private, temporary bookmarks. The operational detail worth knowing is that tags are not pushed by default. git push does not send tags; you need git push --tags or to push the specific tag. That is a common source of "the tag exists locally but CI cannot find it". And tags should be treated as immutable. Moving a tag that others have fetched causes real confusion, because their copy does not update automatically.
What does the index actually store?
A sorted list of path entries, each with the blob hash for the staged content, the file mode, and cached stat information — size, modification time, inode. The stat cache is the performance trick. To answer git status, Git would otherwise have to hash every file in the working directory. Instead it compares the stat data; if the file has not been touched, it skips hashing entirely. That is why status is fast on large repositories, and why it can be slow after an operation that touches every file's timestamp. The index also holds merge conflict state. During a conflict, a path has up to three entries at different stages — the common ancestor, our version, and theirs — which is what lets git checkout --ours and --theirs work, and what git status reads to report unmerged paths. The practical consequence people trip over: staging captures content at the moment of git add. Editing the file afterwards leaves the staged version unchanged, so the commit contains the earlier content. git status shows the file as both staged and modified, and reading that carefully is what prevents committing the wrong version.
How does Git determine whether a file was renamed?
It does not record renames. Git stores snapshots, and a rename is simply a file disappearing from one path and appearing at another. Rename detection is inferred at display time. When you run git log, git diff or git status, Git compares blobs between the two trees and, if a deleted file and an added file have similar enough content, reports it as a rename with a similarity percentage. The threshold defaults to 50% and is adjustable with -M. The consequences are practical. A rename combined with substantial edits in one commit may not be detected, so history looks like a delete and an add, and git log --follow loses the trail. Renaming and editing in separate commits preserves the connection much better, which is a genuinely useful habit. It also explains why git blame can lose history across a rename unless you pass -C or --follow. The design rationale is that inferring renames is more robust than recording them: a recorded rename is wrong if someone else moved the file differently, whereas inference works on whatever the content actually shows. It also keeps the object model simple.
What is the difference between .gitignore and .git/info/exclude?
.gitignore is committed to the repository and shared with everyone. It is the right place for build output, dependency directories, and anything generated that no one should commit. .git/info/exclude is local to your clone and never shared. It is the right place for your personal noise — editor files, local scratch directories — that other people should not have to see in a shared ignore file. There is also a global ignore file configured with core.excludesFile, which is the correct home for editor and OS artefacts like .DS_Store or .idea, since those are properties of your setup rather than of the project. The rule of thumb: project-specific and universal goes in .gitignore; personal goes global or in info/exclude. The most important thing to know about all of them is that ignore rules only affect untracked files. A file already tracked continues to be tracked and its changes continue to show, no matter what you add to .gitignore. To stop tracking it you need git rm --cached, and that removal must be committed. That catches almost everyone at least once, usually with a config file containing a secret.
What is the reflog and why is it the most useful recovery tool?
The reflog records every position HEAD and each branch has held locally — every commit, checkout, reset, merge, rebase and pull. It is what makes almost everything recoverable. A commit becomes unreachable when nothing points at it, and unreachable objects are eventually garbage collected — but the reflog still references them, so they are both findable and protected. So after a hard reset that discarded work, git reflog shows the commit you were on before, and git reset --hard to that hash restores it. After a botched rebase, the reflog has the pre-rebase position. After deleting a branch, the reflog shows the commit it pointed at. The crucial caveats. It is local — it never leaves your machine, so it cannot recover something that only existed on a colleague's computer. It only records where refs pointed, so it cannot recover work that was never committed. Uncommitted changes destroyed by a hard reset are genuinely gone. And entries expire, by default after 90 days for reachable commits and 30 for unreachable ones. The habit worth building is to commit before anything risky, so the reflog has something to find.
What happens during git gc?
Garbage collection packs loose objects into packfiles for efficiency and removes objects that are unreachable and past their expiry. Packing is the routine part: thousands of individual object files are compressed into a packfile with delta compression between similar objects, which dramatically reduces size and speeds up operations. Pruning is the destructive part. An object is unreachable if no branch, tag, or reflog entry leads to it. Unreachable objects older than the grace period — two weeks by default — are deleted permanently. The reflog is what usually protects recently-orphaned commits, which is why recovery works for a while and then stops. Git runs gc automatically when loose objects accumulate, so it is rarely invoked by hand. The case where it matters is deliberately destroying data — after removing a secret from history with filter-repo, the old commits are unreachable but still present in the repository. Expiring the reflog and running git gc --prune=now is what actually deletes them. The corollary is a warning: if you have just realised you lost something, do not run aggressive gc. Find it in the reflog or with git fsck --lost-found first.
What is the difference between origin/main and main?
main is your local branch — a pointer you control, which moves when you commit. origin/main is a remote-tracking branch: a local record of where main was on the remote the last time you fetched. You cannot commit to it, and it moves only when you fetch or push. The distinction matters because origin/main is a cache, not a live view. If someone pushed five minutes ago and you have not fetched, your origin/main is stale and any comparison against it is comparing against old information. That is why "git log origin/main..main shows nothing to push" can be wrong — fetch first. The upstream relationship connects them: main is configured to track origin/main, which is what lets git status say "your branch is ahead by 3 commits" and what makes bare git push and git pull know where to go. Setting it is git push -u on the first push, or git branch --set-upstream-to. There is also FETCH_HEAD, which records what the last fetch retrieved, and ORIG_HEAD, which records the position before a potentially destructive operation — useful for undoing a merge or reset.
Why do two identical-looking commits have different hashes?
Because a commit hash covers more than the content. It includes the tree hash, the parent hashes, the author name, email and timestamp, the committer name, email and timestamp, and the message. So two commits with identical file changes differ if anything else differs — most commonly the parent, the timestamp, or the committer. This explains several behaviours that otherwise seem strange. After a rebase, every commit has a new hash even though the changes are unchanged, because the parents changed. That is why rebasing published history is disruptive: everyone else's commits no longer match. A cherry-pick produces a new hash for the same change, which is why the same fix on two branches appears as two commits. Amending a commit produces a new hash even if you only edited the message, because the message is part of the hash. The author and committer distinction shows up here too: a rebase preserves the original author but sets you as the committer, so the hashes change while git log still shows the original author. The patch-id is the tool for identifying the same change across different hashes.
What is a fast-forward merge and when does it not happen?
A fast-forward happens when the target branch has no commits that the source branch lacks — the source is strictly ahead. Git simply moves the branch pointer forward, since no new commit is needed to combine anything. It does not happen when both branches have diverged, meaning each has commits the other does not. Then Git creates a merge commit with two parents, joining the histories. The practical decision is whether you want fast-forwards. With --no-ff, Git always creates a merge commit, which preserves the fact that a branch existed and groups its commits visibly. Some teams require this because it makes feature boundaries obvious in the history and makes reverting a whole feature a single operation. With --ff-only, Git refuses to merge unless it can fast-forward, which enforces a linear history — you must rebase first. That is a good setting for pull, since it prevents accidental merge commits from a routine pull. The trade is between a history that records how work actually happened, with merge commits and parallel branches, and one that reads as a clean sequence. Both are defensible; consistency within a team matters more than which you pick.
How does a three-way merge work?
Git finds the merge base — the most recent common ancestor of the two branches — and then compares each branch against that base rather than against each other. For each file, if only one side changed it relative to the base, that side's version wins. If both sides changed the same region differently, that is a conflict and Git asks you to resolve it. If both made the identical change, there is no conflict. Comparing against the base is what makes this three-way rather than two-way, and it is why Git can merge automatically in cases where a naive comparison could not tell which side changed. The merge base is found with git merge-base, and a history with multiple merge bases — criss-cross merges — is handled by the recursive strategy, which merges the bases themselves to produce a virtual one. The practical consequence: a conflict does not mean Git failed, it means two people genuinely changed the same lines and only a human knows the intent. And conflicts are more likely the longer a branch lives, because the base recedes further into the past and more can have changed on both sides — which is the strongest argument for short-lived branches.
How do you resolve a merge conflict properly?
Understand both intents before editing. The conflict markers show your version between HEAD and the separator, and theirs after it, but the markers alone do not tell you why each side made its change. git log --merge shows the commits on both sides that touched the conflicting file, which is usually the fastest way to understand the intent. git diff during a conflict shows a combined diff highlighting what differs from both sides. Use merge.conflictStyle set to zdiff3, which additionally shows the original base version. Seeing what the code looked like before both changes makes it far easier to reconstruct the correct result than seeing only the two outcomes. The resolution is rarely picking one side. It is usually combining both intents, which is why --ours and --theirs are blunt tools appropriate mainly for generated files and lock files. After resolving, stage the file and continue. Crucially, build and run the tests before committing — a syntactically valid merge that combines two changes incorrectly is very easy to produce and the conflict markers are gone by then. And if it goes badly, git merge --abort returns you to before the merge.
What is the difference between merge and rebase?
Merge combines two branches by creating a commit with both as parents. History is preserved exactly as it happened, including the fact that work proceeded in parallel. Rebase replays your commits on top of the target branch, creating new commits with new hashes. The result is linear, as though you had started from the current tip. The merge argument: it is honest about what occurred, it is non-destructive, and it is safe on shared branches because it does not alter existing commits. The cost is a history with many merge commits that can be hard to read. The rebase argument: linear history is much easier to read and to bisect, and each commit is a clean step. The cost is that it rewrites commits, so it must not be done to anything others have pulled. The practical convention most teams settle on is to rebase your own local feature branch to keep it current, and to merge it into the shared branch — often squashed. That gives a readable main history without rewriting anything anyone else has. The rule that matters: never rebase commits that exist outside your machine.
What is a squash merge and what do you lose?
A squash merge takes all the changes from a branch and applies them as a single commit on the target, discarding the individual commits. The benefit is a clean main history: one commit per feature or pull request, each self-contained and revertible as a unit. It also means messy work-in-progress commits — "wip", "fix typo", "actually fix it" — never reach the shared history. What you lose is the intermediate steps. If a branch contained a refactoring commit followed by a behaviour change, squashing merges them, so a future bisect cannot separate them and blame attributes everything to one commit and one moment. For a large feature that is a genuine loss. The other consequence is that Git does not record the branch as merged, because the squash commit has no parent link to it. So the branch shows as unmerged, deleting it warns, and merging it again reapplies everything — which produces conflicts. That surprises people who keep working on a branch after it was squash-merged. The usual convention is to squash small pull requests and to merge larger ones with their history intact, having cleaned it up by interactive rebase first.
What is git cherry-pick and when is it appropriate?
Cherry-pick applies the change introduced by a specific commit onto your current branch, creating a new commit with a new hash. The legitimate uses are narrow but real. Backporting a fix to a release branch when you do not want the rest of main. Recovering a single commit from a branch you are abandoning. Moving a commit made on the wrong branch. The reason to be cautious is duplication. The same logical change now exists as two commits with different hashes, so if both branches are later merged Git may or may not notice — it often does, because the patch is identical and applies cleanly to nothing, but if either version was modified you get a conflict that is confusing to resolve. It also fragments history: git log on the release branch shows a fix with no connection to the discussion or the branch it came from. So the guidance is that cherry-picking is a tool for exceptional situations, not a workflow. If you find yourself cherry-picking routinely between long-lived branches, the branching model is probably wrong — the branches have diverged more than they should have. Use -x to record the original hash in the message.
What is git rerere and what problem does it solve?
Reuse Recorded Resolution. When enabled, Git records how you resolved a conflict, and if it sees the same conflict again it applies the same resolution automatically. The problem it solves is repeated conflict resolution. That happens in two situations. Rebasing a long-lived branch repeatedly: each rebase reapplies your commits and each time you resolve the same conflicts against the moving target. And merging a long-running branch periodically to keep it current, where the same divergent regions conflict each time. With rerere enabled, you resolve once and subsequent occurrences are handled for you. It is enabled with rerere.enabled and is entirely local — the recorded resolutions live in .git/rr-cache and are not shared. The caution is that it applies a previous resolution without asking, so if the correct resolution has changed because one side evolved, it silently does the old thing. Reviewing what it did rather than trusting it blindly is worthwhile, and git rerere forget clears a bad recording. It is one of the more obscure features and genuinely useful on a branch that has been alive too long — though the better fix is usually a shorter-lived branch.
How do you keep a long-lived feature branch current?
Integrate frequently, in small increments, rather than in one large operation at the end. The two options are to rebase onto the target regularly, keeping your branch linear and its commits on top of current main, or to merge the target into your branch periodically, which preserves history and is safe if others share the branch. Rebase is generally better for a personal branch: the eventual pull request shows only your changes rather than a tangle of merge commits, and each rebase deals with a small amount of drift. Enable rerere so repeated conflicts are resolved once. But the honest answer is that the best fix is not to have a long-lived branch. Conflict probability grows with divergence, and a branch alive for weeks accumulates them faster than any technique resolves them. The alternatives are to break the work into smaller pieces that merge independently, or to merge incomplete work behind a feature flag so it can go to main safely while remaining inactive. That is the trunk-based argument, and it trades the discipline of flags for the elimination of merge pain. For most teams it is the better deal.
What causes a merge conflict that Git could have avoided?
Several kinds of noise produce conflicts that carry no real disagreement. Inconsistent line endings between contributors — one person's editor writing CRLF and another's LF — makes every line look changed. The fix is a .gitattributes file with text=auto normalising line endings in the repository, rather than relying on everyone configuring core.autocrlf identically. Inconsistent formatting: two people running different formatter settings reformats regions nobody edited. The fix is a committed formatter configuration and ideally a pre-commit hook or CI check. Whitespace changes mixed into functional commits, which is why reviewing with -w and keeping formatting changes in separate commits helps. Generated files committed to the repository — lock files, build output, minified bundles — conflict constantly and are rarely mergeable by hand. Lock files need a deliberate strategy, usually regenerating rather than merging. And a file everyone edits, such as a central registry or a giant enum, becomes a contention point. That is a design signal: splitting it reduces conflicts as a side effect of better structure. Most avoidable conflicts are a tooling or structure problem rather than a Git problem.
What are the merge strategies and when would you change from the default?
The default is ort — Ostensibly Recursive's Twin — which replaced recursive as the default in Git 2.34. It handles two branches with a three-way merge, and constructs a virtual merge base when there are several common ancestors. The alternatives are narrow. ours takes your side entirely, discarding the other branch's changes while still recording the merge — useful for marking a branch as merged when you deliberately do not want its content. Note this is different from the -X ours option, which only resolves conflicting hunks in your favour while still taking non-conflicting changes. subtree is for merging a project into a subdirectory of another. The strategy options are more commonly useful than the strategies. -X ours and -X theirs auto-resolve conflicting hunks in favour of one side, which is appropriate for regenerable files and dangerous for source code. -X ignore-space-change reduces whitespace noise. -X patience or -X histogram use different diff algorithms that sometimes produce more sensible conflict regions on heavily-reordered code. In practice, changing the strategy is rare. Changing the diff algorithm or resolving specific files with a strategy option is more common.
What is an octopus merge?
A merge with more than two parents, combining several branches in a single commit. Git creates one automatically if you pass multiple branches to git merge and none of them conflict. The octopus strategy is used by default in that case. The strict limitation is that it refuses to run if there are any conflicts. It cannot handle manual resolution across multiple branches, so it only works when every branch merges cleanly. In practice it is rare and mostly a curiosity. Its legitimate use is combining several independent topic branches that touch disjoint areas — Linux kernel maintainers do this to merge many small pull requests in one operation. For typical application development it offers little. Merging branches one at a time gives the same result with clearer history and the ability to resolve conflicts as they arise. The reason it appears in interviews is that it demonstrates the commit model: a commit has a list of parents, and nothing restricts that list to two. Merge commits with two parents are just the common case, not a structural rule. Git's own repository contains octopus merges with many parents, which is a good illustration.
How do you find which commit introduced a bug?
git bisect, which performs a binary search over history. Mark a commit where the bug exists as bad and one where it does not as good. Git checks out a commit halfway between, you test it and report good or bad, and it narrows from there. Over a thousand commits it takes about ten steps. The important refinement is automation. git bisect run with a script that exits zero for good and non-zero for bad performs the whole search without interaction, which turns an hour of manual testing into a couple of minutes. That is what makes bisect genuinely practical, and most people never use it. The prerequisites are what determine whether it works. Every commit must build and run, or you will hit commits that cannot be tested — git bisect skip handles a few, but a history where half the commits are broken defeats it. That is a strong argument for keeping every commit working, which people often dismiss as pedantry. A reliable reproduction is also needed; an intermittent bug produces wrong answers. And squashed history reduces resolution: you find the pull request, not the line.
What does git blame tell you and what are its limits?
git blame annotates each line of a file with the commit that last modified it, plus the author and date. Its limits are significant and worth knowing. It shows the last change, not the origin. A line moved during a refactoring, or reindented, is attributed to whoever did that rather than to whoever wrote the logic. A whole-file reformat destroys the useful history entirely. The mitigations: -w ignores whitespace changes, -M detects lines moved within a file, and -C detects lines copied from other files. Those three flags dramatically improve results and are rarely used. Better still, .git-blame-ignore-revs lets you list commits — bulk reformats, license header additions — that blame should skip, and configure blame.ignoreRevsFile so it applies automatically. GitHub honours this file too. The cultural caution is worth mentioning: blame is a diagnostic tool for understanding why code is the way it is, and the useful next step is reading the commit message and the pull request discussion. Finding the commit is the beginning of the investigation, not the end, and the name attached is rarely the interesting part.
What is a merge commit and should you avoid them?
A merge commit is one with two or more parents, recording that two lines of development were combined. Whether to avoid them is a genuine trade-off rather than a settled question. The case against: a history full of merge commits, particularly from routine pulls, is hard to read. git log shows an interleaved tangle, and understanding the sequence of changes takes effort. Bisecting across merges is more complex. The case for: they record what actually happened, including that work proceeded in parallel. They preserve context, and reverting a feature is a single revert of its merge commit rather than of many individual commits. The distinction that resolves most of the argument is between meaningful and incidental merges. A merge commit recording that a feature branch was integrated carries information. A merge commit created because someone ran git pull on a diverged branch carries none — it is noise. So the common convention is to eliminate incidental merges by configuring pull.rebase or pull.ff only, while keeping deliberate merges of feature branches, often with --no-ff so the branch structure is visible.
What does git rebase actually do, step by step?
It finds the commits on your branch that are not on the target, saves them as patches, resets your branch to the target, and reapplies each patch in order. Each reapplied commit is a new object with a new hash, because its parent changed. The content may be identical, but the identity is not. If a patch does not apply cleanly, the rebase stops and asks you to resolve. After resolving you stage the files and run git rebase --continue; --skip drops that commit; --abort returns everything to the starting state. Two consequences follow from the mechanism. Conflicts can occur once per commit rather than once for the whole branch, because each patch is applied to a different intermediate state. A branch with twenty commits can present the same conflict twenty times, which is what rerere addresses. And the original commits still exist in the reflog until garbage collection, which is why a botched rebase is recoverable — git reflog shows the pre-rebase HEAD and a hard reset to it restores everything. The author is preserved on each commit but the committer becomes you, and the commit date changes.
What is interactive rebase used for?
Rewriting a series of your own commits before sharing them — git rebase -i opens an editor listing the commits with an action for each. pick keeps the commit. reword changes the message. edit stops so you can amend the content. squash combines it into the previous commit, prompting for a combined message. fixup does the same but discards this commit's message. drop removes it. And reordering the lines reorders the commits. The practical uses: combining "fix typo" and "address review comments" commits into the change they belong to; splitting a commit that did two unrelated things; correcting a message; and removing a debugging commit that should never have existed. The workflow that makes it efficient is git commit --fixup pointing at an earlier commit, then git rebase -i --autosquash, which places the fixups correctly and marks them automatically. That turns cleanup into two commands. The rule is unchanged: only on commits that have not been pushed to a shared branch. Rewriting published history forces everyone else into a recovery procedure. The goal is a history where each commit is a coherent, reviewable, revertible step.
Why is rebasing published history dangerous?
Because rebase replaces commits with new ones, and everyone else still has the old ones. After you force-push a rebased branch, a colleague who pulls has both histories: their local branch pointing at the old commits and the remote pointing at new ones with different hashes. A plain pull merges them, so every change appears twice — once as the original commit and once as its rebased twin. The history becomes a mess and resolving it requires understanding what happened. Worse, work they committed on top of the old commits is now based on commits that no longer exist upstream, and recovering it means rebasing their work onto the new base. And if you force-push while someone else has pushed in between, their commits are removed from the branch entirely — recoverable from their reflog, but only if they notice. Hence the rule: rebase only commits that exist solely on your machine. If you must force-push a shared branch — a pull request branch you own is the common legitimate case — use --force-with-lease, which refuses if the remote has moved since you last fetched, and tell the people affected.
What is the difference between --force and --force-with-lease?
--force overwrites the remote branch unconditionally, discarding whatever is there. --force-with-lease overwrites only if the remote is where you last saw it. If someone pushed since your last fetch, it refuses. The difference matters when a colleague pushes to your branch between your fetch and your push. With --force their commits are silently destroyed. With --force-with-lease the push is rejected and you can look at what arrived. So --force-with-lease should be the default, and aliasing it to something short makes that easy. The caveat worth knowing: the lease is based on your remote-tracking ref, so anything that updates it without your knowledge weakens the protection. A background fetch — some IDEs and shell prompts fetch automatically — updates origin/branch, so the lease now reflects their commit and the force succeeds. Passing the expected commit explicitly closes that gap. Git 2.30 added --force-if-includes, which additionally checks that your local branch actually incorporates the remote tip, addressing exactly this hole. And on the server side, protected branches that reject force pushes are the real safety net for shared branches.
What is git commit --amend and when should you not use it?
Amend replaces the previous commit with a new one combining the old commit's changes with whatever is currently staged, and optionally a new message. It is a rewrite, not an edit: the resulting commit has a different hash. That is the whole basis of when not to use it. Use it for the commit you just made, before pushing: you forgot a file, made a typo in the message, or want to fold in a small correction. Do not use it on a commit that has been pushed to a shared branch, because you have replaced a commit others have. It requires a force push and creates the same problems as any published rewrite. The subtle mistake is amending with unintended changes staged. Amend takes whatever is in the index, so a stray staged file gets folded into a commit where it does not belong — and because the commit already existed, the addition is easy to miss in review. Checking git status before amending is worth the second. Also note that amending changes the committer and commit date but preserves the author date, so the commit may appear out of chronological order.
How do you split one commit into several?
Interactive rebase to that commit with the edit action, which stops with the commit applied. Then git reset HEAD~ to undo the commit while keeping the changes in the working directory — the commit is gone, the files are as they were. Now stage and commit in pieces. git add -p is the key tool: it walks through the diff hunk by hunk and lets you stage selectively, so you can separate two changes that live in the same file. For finer control, the split and edit options within add -p let you divide a hunk or edit the staged version by hand. Commit each logical group, then git rebase --continue. The part people find fiddly is that the split must be clean: if the second half depends on the first, committing them separately is fine, but if you interleave them the intermediate commit may not build. Checking that each commit compiles is worth doing, since a history where every commit works is what makes bisect usable. The same add -p technique is how you avoid needing to split later — staging selectively as you work produces coherent commits in the first place.
How do you remove a secret that was committed?
First, treat it as compromised and rotate it. Anything pushed to a remote must be assumed captured — by CI logs, by clones, by GitHub's own caching, by scanners. Removing it from history does not undo exposure, and rotation is the only real remediation. Then rewrite history to remove it. git filter-repo is the current tool; the old filter-branch is slow and error-prone and its own documentation recommends against it. BFG Repo-Cleaner is a simpler alternative for this specific case. Rewriting changes every commit hash from the point of introduction, so everyone must reclone or carefully reset. Coordinate it. On the server, the old objects may persist until garbage collection, and forks and pull requests can retain them — on GitHub you generally need to contact support to purge cached views. Then prevent recurrence: a pre-commit hook or a scanning tool such as gitleaks in CI, and secrets in environment variables or a secret manager rather than in files. The order matters. People often start with the history rewrite, which is the least urgent step. Rotate first.
What is git filter-repo and why replace filter-branch?
Both rewrite history across many commits — removing a file, changing author details, extracting a subdirectory into its own repository. filter-branch was the original. It is extremely slow because it spawns processes per commit, it has confusing defaults that silently leave references behind, and it is easy to produce a partially rewritten repository. Git's own documentation now warns against it and points at filter-repo. filter-repo is a separate Python tool, dramatically faster, with safer defaults. It rewrites all refs including tags, cleans up the reflog and original refs, and refuses to run on a repository with uncommitted changes or that is not a fresh clone — which prevents the common accident of half-rewriting your working repository. The use cases are the same: purging a large file or a secret, splitting a monorepo, or correcting author email across history. The consequences are unchanged and severe. Every hash changes, so every clone is invalidated, open pull requests break, and tags must be re-pushed. It is a coordinated operation, not something to do casually. For removing large files specifically, BFG is simpler and adequate.
What does git rebase --onto do?
It rebases a specific range of commits onto a new base, rather than rebasing everything since the merge base. The form is git rebase --onto newbase upstream branch: take the commits in branch that are not in upstream, and replay them onto newbase. The case it solves is a branch built on the wrong parent. You branched feature-b off feature-a, then feature-a was abandoned or merged differently, and you want only your feature-b commits on main. A plain rebase onto main would bring feature-a's commits along; --onto lets you say explicitly which commits to move. It is also how you drop a range of commits from the middle of a branch: rebase the commits after them onto the commit before them. The mental model is three points rather than two: where to put them, what to exclude, and what to move. Getting the argument order wrong produces surprising results, so checking with git log upstream..branch first — which lists exactly the commits that will be replayed — is a good habit. It is the command that makes rebase genuinely powerful rather than just a merge alternative.
What makes a good commit message?
A short imperative subject line under about fifty characters, a blank line, then a body explaining why. Imperative mood — "Fix the null check" rather than "Fixed" — because it completes the sentence "this commit will...", and it matches what Git itself generates for merges and reverts. The body is the part that matters and the part usually missing. The diff already shows what changed; the message should explain why it needed to change, what the alternative approaches were, and anything a future reader would find surprising. A message saying "fix bug" adds nothing that git log --stat does not. Reference the issue or ticket so the discussion is findable. Wrap the body at around 72 characters, since Git does not wrap it for you. The test worth applying: will this help someone in two years running git blame on a confusing line? If the message only restates the diff, it fails that test. Conventional Commits adds a machine-readable prefix, which enables automated changelogs and version bumps. Useful if you want that automation, ceremony if you do not.
How should you structure a series of commits in a pull request?
Each commit should be a coherent step that builds, passes tests, and can be reviewed on its own. The practical shape: separate refactoring from behaviour change. A commit that moves code without changing it, followed by a commit that changes behaviour, is far easier to review than one that does both — the reviewer can verify the first is a no-op and focus attention on the second. Keep formatting and renaming in their own commits for the same reason. Order them so the reasoning unfolds: preparation first, then the change, then cleanup. Avoid commits that exist only because of the process — "address review comments" should be squashed into the commit being corrected, using git commit --fixup and --autosquash. The payoff is not just review. A history of coherent commits makes bisect precise, makes blame informative, and makes reverting a single change possible. The counter-argument is that this takes effort and many teams squash on merge anyway, which discards it. If your team squashes, the effort is better spent on the pull request description. If it does not, commit hygiene is what keeps the history usable.
What is git commit --fixup and --autosquash?
git commit --fixup=abc123 creates a commit whose message is "fixup! " followed by the target commit's subject. git rebase -i --autosquash then automatically reorders that commit to sit immediately after its target and marks it as fixup, so the interactive editor opens with everything already arranged. You confirm and it folds in, discarding the fixup message. --squash is the variant that keeps the message for you to combine. The workflow this enables is valuable during review. Rather than adding a "address comments" commit that pollutes the history, you make a fixup targeting the commit that needs correction, push it so the reviewer can see just that change, and squash before merging. The reviewer sees incremental changes; main gets clean commits. Set rebase.autoSquash to true so the flag is not needed each time. The practical tip is finding the target hash quickly — git log --oneline on the file you changed, or git blame on the line — which is the only fiddly part. It is a small feature that meaningfully changes how tidy a branch stays without extra discipline.
When is it acceptable to rewrite history?
When the commits exist only on your machine, or on a branch that is understood to be yours alone. Local cleanup before pushing is unambiguously fine and is good practice — squashing fixups, correcting messages, splitting commits. A personal feature branch on a shared remote is the grey area. If the convention is that a pull request branch belongs to its author and may be force-pushed, rewriting is fine, and --force-with-lease plus a note in the pull request handles the risk. Many teams work this way and it is reasonable. A shared branch that others build on — main, develop, a release branch — should never be rewritten. The cost is imposed on everyone. The exception is a genuine emergency: a leaked secret or a file that must be removed for legal reasons. Then rewriting is justified, but it is a coordinated operation with everyone informed, not a unilateral force push. The underlying principle is that history is a shared artefact once published. Rewriting it means invalidating everyone else's copy, so the question is not whether you can but whether the cost to others is justified.
How do you recover from a rebase that went wrong?
If the rebase is still in progress, git rebase --abort returns everything to the state before it started. That is the clean escape and it always works while the rebase is running. If the rebase completed and the result is wrong, the reflog is the tool. git reflog shows every position HEAD held, including the commit your branch pointed at immediately before the rebase — usually labelled with "rebase" in the entries. git reset --hard to that hash restores the pre-rebase branch exactly. ORIG_HEAD is a shortcut: Git sets it before potentially destructive operations, so git reset --hard ORIG_HEAD often works directly. If you already force-pushed the bad result, the same recovery applies locally and you force-push again — with --force-with-lease — to restore the good state. Anyone who pulled in between has the bad version and needs telling. The thing that is genuinely unrecoverable is uncommitted work destroyed during the process, since the reflog only tracks commits. Committing or stashing before starting a rebase is the habit that makes everything else recoverable. And do not run git gc while trying to recover.
What is a remote and how do remote-tracking branches work?
A remote is a named URL — origin by convention — pointing at another repository. Remote-tracking branches such as origin/main are local references recording where that remote's branches were at your last fetch. They live in refs/remotes/ and you cannot commit to them. The refspec in your config defines the mapping, typically fetching all of refs/heads/* on the remote into refs/remotes/origin/*. The upstream relationship is separate: a local branch can be configured to track a remote-tracking branch, which is what makes bare git push and git pull work and what lets git status report ahead and behind counts. The practical points. origin/main is a cache and only updates on fetch, so stale comparisons are common. git remote prune origin, or fetch --prune, removes remote-tracking branches for branches deleted on the remote — otherwise they accumulate indefinitely and clutter the branch list. And you can have several remotes, which is the normal setup for a fork: origin pointing at your fork and upstream at the original, fetching from upstream and pushing to origin.
What is the difference between git push and git push -u?
-u, or --set-upstream, records the tracking relationship between your local branch and the remote branch it pushes to. Without it, the push works but the branches are not linked. Subsequent bare git push or git pull will not know where to go, and git status cannot tell you whether you are ahead or behind. With it, all of that works and you only need -u on the first push. The configuration that removes the need is push.default set to current, which pushes the current branch to a branch of the same name and — with push.autoSetupRemote in Git 2.37 and later — sets the upstream automatically. That combination removes an annoyance most people just live with. Worth knowing about push.default more broadly: the default is simple, which pushes the current branch only if the upstream has the same name, and refuses otherwise. That is a safety feature preventing accidental pushes to a differently-named branch. The older matching default, which pushed every branch with a matching name on the remote, caused real accidents and was changed in Git 2.0.
How do you contribute to a project you do not have write access to?
The fork and pull request model. Fork the repository on the hosting platform, clone your fork, and add the original as a second remote conventionally named upstream. Work on a branch in your fork, push it there, and open a pull request against the original. Keeping current means fetching from upstream and rebasing your branch onto upstream's main — not pulling from your own fork's main, which does not update itself. That is the step people miss, and it is why forks drift. The practical conventions that make a pull request likely to be accepted: one logical change per pull request, since a large mixed one is hard to review and often stalls. Read the contributing guide, because most projects have specific requirements about tests, formatting and commit messages. Keep the branch rebased so it merges cleanly. And write a description explaining why, not just what. Maintainers may ask you to squash or rebase before merging, which is normal. The alternative for projects using email workflows — the Linux kernel — is format-patch and send-email, which is worth knowing exists even if rarely used.
What is a shallow clone and when is it useful?
git clone --depth 1 fetches only the most recent commit rather than the entire history, producing a much smaller and faster clone. The primary use is CI. A build usually needs the current state, not ten years of history, and on a large repository a shallow clone can cut minutes off every pipeline run. It is also useful for a one-off checkout when you only want the files. The limitations are what to be aware of. History-dependent operations fail or behave oddly: git log shows only the fetched commits, bisect cannot go back, blame is truncated, and git describe cannot find tags. Merging and rebasing against unfetched history does not work. You can deepen later with git fetch --deepen or convert to a full clone with --unshallow. A related option is --filter=blob:none, a partial clone, which fetches all commits and trees but downloads file contents lazily on demand. That preserves history operations while still being much smaller, and is often a better choice than shallow for interactive use on a large repository. Also --single-branch, which is implied by --depth, to avoid fetching every branch.
What are Git submodules and what are their problems?
A submodule embeds another repository at a path in yours, recording a specific commit of the dependency rather than its contents. The appeal is precise version pinning with the ability to develop both together. The problems are numerous and well known. Clone does not fetch submodules by default, so a fresh clone has empty directories until git submodule update --init --recursive — which everyone forgets. Switching branches does not update submodules automatically, so you can be building against the wrong version without noticing. Commits in the parent record only the submodule hash, so changes inside a submodule must be committed and pushed there first, and forgetting leaves a reference to a commit nobody else can fetch. Merge conflicts on submodule pointers are confusing to resolve. The alternatives: a package manager, which is the right answer when the dependency is genuinely a library. Subtrees, which vendor the content into your repository, so clones are simple at the cost of a larger repository and awkward upstream merges. Or a monorepo. Submodules earn their place when you genuinely need separate repositories developed in lockstep.
What is Git LFS and when do you need it?
Large File Storage replaces large files in the repository with small text pointers, storing the actual content on a separate server and fetching it on checkout. The problem it solves is that Git stores every version of every file forever, and it does not delta-compress binaries well. A 50 MB design file edited weekly adds 50 MB to the repository each time, and every clone downloads all of it. Repositories become gigabytes and clones take forever. LFS keeps only pointers in history, so the repository stays small and clients fetch only the versions they check out. The costs: it requires the LFS client installed, or checkouts contain pointer files instead of content — a confusing failure for someone without it. It needs server support and often storage quota that costs money. And converting an existing repository means rewriting history. The alternative worth considering first is not committing large binaries at all — build artefacts belong in an artefact store, and large assets often belong in object storage referenced by the repository. LFS is right when the binaries genuinely need versioning alongside the code, as in game development or design assets.
How do Git hooks work and what are they used for?
Hooks are executable scripts in .git/hooks that Git runs at defined points. If a hook exits non-zero, the operation is aborted. Client-side hooks include pre-commit, which is the usual place for linting and formatting checks; commit-msg for validating message format; and pre-push for running tests before code leaves the machine. Server-side hooks include pre-receive and update, which can reject a push — enforcing branch protection, commit signing, or message conventions — and post-receive for triggering deployments and notifications. The crucial limitation is that client hooks live in .git/hooks, which is not part of the repository and is not cloned. So they cannot be relied on for enforcement: a contributor without the hook installed simply bypasses it, as does anyone passing --no-verify. The practical solution is a tool that manages hooks from a committed configuration — Husky, pre-commit, or Lefthook — combined with the same checks in CI. Hooks give fast local feedback; CI provides the actual enforcement. Relying on client hooks alone for anything that matters is a common mistake.
What is a bare repository?
A repository with no working directory — just the contents of what would normally be the .git directory, conventionally in a directory ending in .git. It exists to be pushed to. A repository with a working directory has a checked-out branch, and pushing to that branch would make the working directory disagree with HEAD, which Git refuses by default because it is confusing and can lose work. So every server-side repository is bare: GitHub's copy of your project, and anything you set up with git init --bare. The practical relevance is mostly when self-hosting or building tooling. Setting up a remote on a server means creating a bare repository there and pointing your remote at it — no hosting platform required, which is a useful thing to know. It also comes up with git worktree, which uses a shared repository across several working directories. The related setting is receive.denyCurrentBranch, which controls what happens if you do push to the checked-out branch of a non-bare repository — refuse by default, or updateInstead, which updates the working directory too and is occasionally used for simple deployment.
How do you handle a large binary accidentally committed?
The file is in history, so simply deleting it in a new commit does not shrink the repository — every clone still downloads it forever. Removing it requires rewriting history with git filter-repo or BFG, stripping the blob from every commit that contains it. That changes every subsequent hash, so it invalidates all clones and open pull requests and must be coordinated. After the rewrite, expire the reflog and run aggressive garbage collection so the objects are actually deleted locally, and note that the remote may retain them until its own maintenance runs — on hosted platforms you often have to ask support. Whether it is worth doing depends on size and pain. A 5 MB file in an otherwise small repository is annoying; a 2 GB one that makes every clone take twenty minutes justifies the disruption. Prevention is better: a pre-receive hook rejecting large files, or a pre-commit check, plus .gitignore covering build output. GitHub warns above 50 MB and blocks above 100 MB, which catches the worst cases. And if large binaries are a legitimate need, set up LFS before they accumulate rather than after.
What is the difference between a fork and a branch?
A branch is a pointer within one repository. A fork is a separate copy of the whole repository, usually on a hosting platform, with its own branches and its own access control. The distinction is about permission and ownership rather than about Git — Git itself has no concept of a fork. A fork is just a clone that the platform tracks as related, enabling pull requests between them. Forks are the model for open source contribution, where you have no write access to the original. They are also used to maintain a long-lived divergent version of a project. Branches are the model within a team where everyone has write access, and they are simpler: no second remote, no syncing a fork, and everyone sees the branches. The practical friction with forks is keeping them current. Your fork's main does not update itself; you fetch from upstream and update it, and forgetting is why forks fall months behind. Some teams use forks internally for stricter access control, so nobody can push to the main repository. That is defensible but adds friction, and branch protection rules usually achieve the same thing more simply.
How do you sign commits and why would you?
Configure a signing key — GPG traditionally, or SSH signing since Git 2.34, which is much simpler since you can reuse your existing SSH key — and commit with -S, or set commit.gpgsign to true to sign everything. The reason is that Git's author field is entirely unauthenticated. Anyone can set user.name and user.email to yours and commit as you; nothing verifies it. A signature is cryptographic proof that the commit came from the holder of a specific key. That matters for projects where provenance is a security property — anything where a malicious commit attributed to a trusted maintainer would be damaging. It is also increasingly required for supply chain assurance. Hosting platforms show a verified badge for signed commits whose key is registered to the account, and branch protection can require signatures. The practical friction: key management, signing on every machine you use, and the fact that rebasing or amending requires resigning. Tools that rewrite history need the key available, and CI systems that create commits need one too. Tagging releases with signed annotated tags is the highest-value application if you sign nothing else.
What is git worktree and what problem does it solve?
git worktree lets one repository have several working directories, each with a different branch checked out, sharing the same object database. The problem it solves is needing to be in two places at once. You are mid-change on a feature, an urgent bug arrives, and you must switch branches — which normally means stashing or committing incomplete work, switching, fixing, switching back, and restoring. With a worktree you create a second directory on the hotfix branch, work there, and your feature directory is untouched. No stash, no context loss. It is also useful for running a long build or test suite on one branch while working on another, and for comparing two versions side by side with real files rather than diffs. The advantage over a second clone is that objects are shared, so it costs almost no disk and no extra fetching — and branches and stashes are common across worktrees. The constraints: the same branch cannot be checked out in two worktrees simultaneously, and you should remove worktrees with git worktree remove rather than deleting the directory, or the administrative files are left behind.
What is the difference between git reset --soft, --mixed and --hard?
All three move HEAD to the specified commit. They differ in what else they touch, and the three-trees model explains it exactly. --soft moves HEAD only. The index and working directory are untouched, so the changes from the discarded commits appear as staged changes ready to recommit. This is how you redo a commit differently. --mixed, the default, moves HEAD and resets the index to match. Changes appear as unstaged modifications in the working directory. This is how you uncommit and restage selectively. --hard moves HEAD, resets the index, and overwrites the working directory. All changes are discarded. The critical distinction is that --soft and --mixed are safe — nothing in the working directory is lost, so a mistake is recoverable by simply committing again. --hard destroys uncommitted work permanently; the reflog cannot recover it because it was never committed. So the habit worth having is to commit or stash before any --hard reset. And when the intent is to discard, --hard on a commit is fine because the commits themselves remain in the reflog.
When do you use git revert instead of git reset?
Revert when the commit has been pushed to a shared branch. Reset when it has not. Revert creates a new commit that applies the inverse of the target commit. History is unchanged — the original commit remains, and a new one undoes it. That is safe on shared branches because nobody else's history is invalidated. Reset moves the branch pointer, removing commits from the branch. On a shared branch that requires a force push and imposes recovery on everyone. So the rule follows from whether the commits are published. The practical detail with revert is reverting a merge commit. A merge has two parents, so Git cannot tell which side to undo; you must specify with -m 1 to keep the first parent, meaning undo the merged branch. The consequence people hit later: once a merge is reverted, merging that branch again does nothing, because Git considers it already merged. Getting the changes back requires reverting the revert. Revert also leaves the history honest — it shows that something was introduced and then removed, which is usually what you want in a shared branch's record.
How do you undo changes to a file in the working directory?
The modern commands are git restore for files and git switch for branches, introduced in Git 2.23 to split the overloaded git checkout into two clearer tools. git restore path discards unstaged changes, replacing the working file with the index version. git restore --staged path unstages, moving the index back to HEAD while leaving the working file. git restore --source HEAD --staged --worktree path resets both. The old equivalents are git checkout -- path and git reset HEAD path, which still work. The important warning is that restoring an unstaged change is genuinely destructive. That content was never committed and never staged, so the reflog cannot help — it is gone. Git prints no warning and asks no confirmation. That is the single most common way people permanently lose work, usually by running it on more paths than intended. The safer habit for anything non-trivial is to stash rather than discard: git stash keeps the changes retrievable, and dropping the stash later is a deliberate second decision rather than an irreversible first one.
How does git stash work and what are its pitfalls?
Stash saves your uncommitted changes and reverts the working directory to HEAD, storing the changes as commit objects on a stack under refs/stash. git stash pop applies the top stash and removes it; git stash apply applies without removing, which is safer if the application might conflict. The pitfalls. By default it does not stash untracked files, so a new file stays in the working directory and can be lost or cause confusion when switching branches — use -u to include them. Ignored files need -a. The stash is a stack with no branch association, so stashes accumulate and it becomes unclear which belongs to what. Always use git stash push -m with a message, because "WIP on main" for the fifth time tells you nothing. pop with a conflict leaves the stash in place and the working directory conflicted, which surprises people expecting it to be gone. And stashes are local and are not pushed, so they are lost if the machine is. The better habit for anything substantial is a work-in-progress commit on a branch, which is visible, named, pushable and recoverable through the reflog.
How do you recover a deleted branch?
Deleting a branch removes a pointer, not the commits, so the work survives until garbage collection. git reflog shows every position HEAD has held, including the commits that were on the branch. Find the tip commit and create a branch at it: git branch recovered abc123. If the branch was deleted without ever being checked out — so it does not appear in HEAD's reflog — the branch's own reflog may still exist, and git reflog show branchname can help before it is cleaned up. Failing that, git fsck --lost-found lists unreachable commits, which is the last resort and produces a lot of noise to sift through. The cases where recovery genuinely fails: the commits were garbage collected, which takes at least two weeks by default; or the branch existed only on a remote and was deleted there, in which case your local reflog has nothing unless you had fetched it. For a branch deleted on a remote, hosting platforms often let you restore it from the UI, and the remote's own reflog may have it. The preventive habit is simply pushing branches you care about.
How do you undo a git push?
It depends on whether others may have pulled, and the safe answer differs from the tidy one. The safe approach is git revert: create commits undoing the changes and push normally. History is preserved, nobody's clone breaks, and the record shows what was introduced and removed. This is correct for a shared branch. The tidy approach is to reset locally and force-push, removing the commits from history. This is only acceptable on a branch that is yours, and it should use --force-with-lease so you do not destroy someone else's concurrent push. If you force-push a shared branch, everyone who pulled has the old commits and a plain pull will merge them straight back in, so you must tell people and give them the recovery command — typically git fetch and git reset --hard origin/branch, which discards their local state. The practical judgement: for a bad commit on main, revert. For a wrong force-push or a leaked secret on a shared branch, coordinate a rewrite. And protected branches that reject force pushes prevent the worst version of this by making the decision for you.
How do you recover a commit after a hard reset?
git reflog, which lists every position HEAD has held with an index like HEAD@{2}. Find the entry from before the reset — the reflog labels operations, so the reset itself is visible and the entry above it is where you were. Then git reset --hard to that hash, or safer, create a branch at it first so you can inspect before moving. ORIG_HEAD is often a shortcut, since Git records the pre-operation position there for reset, merge and rebase. What this recovers is committed work. What it cannot recover is anything uncommitted at the time of the reset, because the reflog only tracks where references pointed and uncommitted changes were never referenced by anything. That asymmetry is the important part of the answer: git reset --hard on a commit is recoverable, and git reset --hard destroying your working directory changes is not. The expiry matters too — reflog entries for unreachable commits default to 30 days, so this works for recent mistakes rather than archaeology. And do not run git gc while trying to recover; find the commit first.
How do you remove a file from the last commit without losing it?
If the commit is not pushed, the simplest route is to unstage the file and amend. git restore --staged --source HEAD~1 path restores the index entry for that path to its previous state, then git commit --amend produces a commit without that file. The working directory copy is untouched, so the file itself is still there. If the file should not exist at all, git rm --cached path removes it from tracking while leaving it on disk, then amend. The common case is a file that should never have been committed — a config with credentials, a large binary, an editor artefact. Removing it from the last commit is easy; removing it from older history requires filter-repo. The distinction worth being precise about is between git rm, which deletes the file, and git rm --cached, which only stops tracking it. Using the wrong one deletes work. And add it to .gitignore in the same commit, or it will be staged again the next time someone runs git add with a broad pattern. Untracking without ignoring is a half-fix that recurs.
What does git clean do and why is it dangerous?
git clean removes untracked files from the working directory. It is dangerous because untracked files have never been committed or staged, so Git has no copy of them anywhere. There is no reflog entry, no stash, no object in the database. Once removed they are gone as surely as if you had deleted them from the shell. The flags are unforgiving. -f is required because it refuses to run without it. -d includes untracked directories. -x also removes ignored files, which means build output and, frequently, local configuration and environment files that took effort to set up. The habit that prevents disasters is git clean -n, the dry run, which lists exactly what would be removed. Running it first takes two seconds and has saved a great many people's local configuration. -i gives an interactive mode for selective removal. The legitimate uses are real: clearing build artefacts to force a clean build, or resetting a working directory to a pristine state. Combined with git reset --hard it gives you exactly the committed state and nothing else. Just always dry-run first.
How do you undo a merge that has already been pushed?
git revert with the -m flag specifying which parent to keep. A merge commit has two parents, so Git cannot infer which side to undo. -m 1 means keep the first parent — the branch you were on when merging — and undo everything the merged branch brought in. -m 2 does the reverse. That produces a new commit undoing the merge, safe on a shared branch. The consequence that catches people out is that reverting a merge does not un-merge it in Git's view. The merge commit still exists in history, so Git still considers the branch merged. Merging the same branch again brings in nothing, because from Git's perspective those commits are already ancestors. To actually reintroduce the work later, you revert the revert. That is a real and legitimate operation, and the Git documentation has a well-known section on it. The alternative — resetting the branch to before the merge and force-pushing — removes the merge entirely and avoids that problem, but it rewrites shared history and is only appropriate if the merge was very recent and nobody has pulled.
How do you fix a commit made on the wrong branch?
If the commit is not pushed, move it. Create a branch at the current position so the commit is not lost — git branch correct-branch — then reset the wrong branch back: git reset --hard HEAD~1 while on it. The commit now lives only on the new branch. If the correct branch already exists, cherry-pick the commit onto it, then reset the wrong branch to remove it. For several commits, git rebase --onto is the precise tool: move the range of commits from the wrong base to the right one. If the commit was already pushed to the wrong branch, you cannot cleanly remove it without rewriting that branch. On a personal branch, reset and force-push with lease. On a shared branch, revert it there and cherry-pick it to the correct branch — the change ends up in the right place and the history honestly records the detour. The preventive habit is checking git status or configuring a shell prompt that shows the current branch, since this mistake is almost always caused by not knowing where you were.
What is git reset --hard ORIG_HEAD and when is it useful?
ORIG_HEAD is a reference Git sets automatically before operations that move HEAD substantially — merge, rebase, reset and pull. It records where you were immediately before. So git reset --hard ORIG_HEAD undoes the last such operation in one command, without having to read the reflog and find the right hash. The typical uses: a merge that went badly and you want to be back where you started; a pull that brought in a surprise merge; a reset to the wrong commit. It is more convenient than the reflog for the immediate previous operation, and the reflog remains the general tool for anything further back. The caveat is that ORIG_HEAD is overwritten by the next qualifying operation, so it only helps for the most recent one. If you run a merge and then a rebase, ORIG_HEAD points at the pre-rebase state and the pre-merge state is only in the reflog. And as always with --hard, uncommitted changes in the working directory are destroyed. ORIG_HEAD restores where HEAD was, not what you had unsaved.
You committed with the wrong author. How do you fix it?
For the most recent commit, git commit --amend --author="Name <email>" --no-edit rewrites it with the correct author while keeping the message. For several commits, an interactive rebase with the exec action running the amend on each, or git rebase --exec, applies it across a range. filter-repo has a --mailmap option designed for bulk author corrections across a whole history. The distinction worth knowing is that a commit has both an author and a committer. Amending sets the committer to you regardless, and rebasing preserves the original author while making you the committer. So git log shows the original author, which is usually what people expect, while the underlying record shows who rewrote it. If the commits are pushed to a shared branch, correcting them means rewriting published history, which usually is not worth it for a cosmetic issue. The alternative that requires no rewriting is a .mailmap file, which maps incorrect names and emails to canonical ones at display time. git log and git shortlog honour it, as do hosting platforms. That fixes the attribution people see without touching history, and is the right answer for historical mistakes. The prevention is per-repository user.email configuration.
What is the safest way to experiment with a risky Git operation?
Make the current state recoverable first, then work on a copy. Commit or stash everything, because the reflog protects commits and cannot protect uncommitted changes. That single step makes almost everything else reversible. Create a backup branch at the current position — git branch backup-before-rebase — so there is a named pointer to return to that does not depend on reading the reflog under pressure. For genuinely dangerous operations such as a history rewrite, work on a fresh clone. filter-repo insists on this for good reason: if it goes wrong you delete the directory and start again, with the original untouched. Note the commit hash before starting, so you can reset to it directly. And know the escape hatches for the specific operation: --abort works during a merge, rebase, cherry-pick or revert, and returns you to the starting state cleanly. The underlying principle is that Git is very good at recovering things it has recorded and completely unable to recover things it has not. Every safety habit reduces to making sure Git has a record before you take a risk.
What is Git Flow and what are its criticisms?
Git Flow defines long-lived main and develop branches plus supporting feature, release and hotfix branches, with prescribed rules for where each starts and merges. It was designed for software with explicit versioned releases — desktop applications, libraries shipping every few months — where you genuinely need to stabilise a release while development continues. The criticisms are substantial for web development. It is heavyweight: two permanent branches and three transient types mean a lot of merging, and the same change often travels through several branches. Long-lived feature branches accumulate conflicts. The develop-to-main separation adds ceremony without benefit when you deploy continuously, since main is not a release artefact but a deployment trigger. Its own author later wrote that it is not the right default and that teams shipping continuously should use something simpler. The alternatives: GitHub Flow, which is main plus short-lived feature branches merged via pull request and deployed continuously. And trunk-based development, where everyone commits to main frequently behind feature flags. The honest answer is that the branching model should match the release model, and most web teams releasing continuously do not need Git Flow.
What is trunk-based development?
Everyone integrates into a single main branch frequently — at least daily — with branches living hours rather than days. The motivation is that merge pain grows with divergence. If nobody's work is more than a day from main, conflicts are small and rare, and the integration problem largely disappears. The mechanism that makes it possible for incomplete work is feature flags: unfinished code is merged but inactive, so main always contains everything while only enabled features are visible. That decouples deploying from releasing, which is valuable independently. The requirements are real. A strong automated test suite, because there is no stabilisation branch catching problems. Fast CI, or frequent integration is impractical. And discipline about keeping main releasable. The benefits: no long-lived branches, no complex merge choreography, continuous integration in the literal sense, and the ability to deploy at any time. The costs: feature flags add complexity and must be removed once a feature is permanent, or they accumulate into an unmaintainable matrix of conditionals. And it demands more rigorous testing than a model with a stabilisation period. It is what most high-performing teams in the DORA research use.
What branch protection rules would you configure?
Require a pull request before merging, so nothing reaches main without review — which is the single highest-value rule. Require status checks to pass, and require the branch to be up to date, so the tests that passed actually ran against the merged result rather than a stale base. Require at least one approving review, and dismiss stale approvals when new commits are pushed, so a review does not carry over to changed code. Block force pushes and branch deletion, which prevents the destructive accidents outright. Beyond those, depending on context: require signed commits where provenance matters, require a linear history if the team has agreed to rebase or squash, and require conversation resolution so review comments are not merged past. Requiring review from code owners is valuable in a larger codebase, routing changes to the people who know the area. The caution worth stating is that too many required checks make merging slow and encourage people to look for ways around them. The rules should reflect real risk — main and release branches protected strictly, feature branches unprotected. And whether administrators are exempt is a genuine decision, not a detail.
Should you squash, rebase, or merge pull requests?
Squash gives one commit per pull request on main. History is clean and each entry maps to a reviewable unit, and messy work-in-progress commits never appear. The cost is losing the intermediate steps, which matters for large changes where the individual commits carried meaning. Rebase and merge replays each commit onto main individually, giving a linear history that preserves the steps. It requires the author to have curated their commits, or main inherits the mess. A merge commit preserves everything including the branch structure, and makes reverting a whole feature one operation. The cost is a busier history. The common convention that works well: squash by default, because most pull requests are one logical change and their commit history is not carefully curated; and merge with history for large changes where the author has deliberately structured the commits. The important thing is consistency, since a history that mixes all three is harder to read than any one of them. And whichever you choose, the squash commit message matters — the default of concatenating every commit subject is noise, and writing a proper message at merge time is worth the moment.
How do you handle hotfixes to production?
Branch from the commit that is actually deployed, not from the current main, because main may contain unreleased changes you do not want to ship. That means either branching from the release tag, or from a maintenance branch tracking what is in production. Make the smallest possible change, test it, and deploy. Then merge or cherry-pick it back to main so the fix is not lost in the next release — forgetting this is how a bug is fixed and then reappears, which is a genuinely common failure. The branching model determines the mechanics. With trunk-based development and continuous deployment, the fastest fix is usually to fix forward on main and deploy, since main is close to production anyway. With versioned releases, you need the hotfix branch from the release tag. Tag the hotfix release so the deployed state remains identifiable. The organisational points matter as much: hotfixes bypass normal process, so the review requirement should be relaxed deliberately rather than by circumventing protection rules, and there should be a follow-up to add whatever test would have caught it.
How do you manage releases with tags?
Tag the commit that is released with an annotated, ideally signed, tag using a consistent scheme — semantic versioning is the usual choice. Annotated rather than lightweight, so the tag records who created it and when, and can be verified. The tag is what makes a release reproducible: given the tag you can check out exactly what shipped, which is what you need when investigating a production issue or preparing a hotfix. The operational details that catch people: tags are not pushed by default, so git push --tags or pushing the specific tag is required, and forgetting means CI cannot find the tag it is supposed to build. And tags should be immutable — moving one that others have fetched does not update their copy, producing two people with different ideas of what a version is. git describe gives a human-readable version from the nearest tag plus the distance and hash, which is useful for embedding a build identifier. Automating the tag from CI on merge to main, driven by conventional commit messages, removes the manual step and the inconsistency — at the cost of requiring the commit convention.
What is a monorepo and what are the Git implications?
A monorepo holds many projects in one repository, sharing history and tooling. The benefits are atomic cross-project changes — a change to a library and every consumer in one commit — a single dependency version, unified tooling, and much easier large-scale refactoring. The Git implications are mostly about scale. The repository grows large, so clones are slow and every developer downloads history for projects they never touch. Operations that scan the working directory — status, checkout — slow down as file counts rise. The mitigations exist and are worth knowing: partial clone with --filter=blob:none fetches file contents lazily; sparse-checkout limits the working directory to the paths you need; and the filesystem monitor speeds up status on large trees. Together these make monorepos workable at very large scale, which is how the big adopters manage. The other implication is CI: building everything on every change does not scale, so you need change detection to build only affected projects, which is what Bazel, Nx and Turborepo provide. And permissions become coarse, since Git has no path-level access control.
How do you review a large pull request effectively?
Start by pushing back on the size, because the most effective intervention is splitting it. Review quality drops sharply with size, and a thousand-line pull request typically gets a cursory look and an approval. If it must be reviewed as is, review commit by commit rather than the combined diff, if the author structured the commits. A refactoring commit can be verified as behaviour-preserving quickly, letting you concentrate on the commits that change behaviour. Use the tools: ignore whitespace to remove reformatting noise, and view the diff ignoring moved code where the platform supports it. Check out the branch locally for anything substantial. Reading a diff in a browser tells you what changed but not whether the result makes sense, and some problems are only visible with the whole file in front of you. Prioritise by risk: correctness in the core logic, error handling, and anything touching security or data. Naming and style are worth less attention and should mostly be automated. And separate blocking concerns from suggestions explicitly, so the author knows what must change.
What Git configuration would you set on a new machine?
user.name and user.email, and consider setting email per repository if you contribute both personally and for work — committing with the wrong address is common and awkward to fix. pull.rebase true or pull.ff only, so a routine pull cannot create a surprise merge commit. init.defaultBranch main, to match current convention. core.excludesFile pointing at a global ignore for editor and OS artefacts, so .DS_Store never has to appear in a project .gitignore. merge.conflictStyle zdiff3, which shows the original version alongside both sides during a conflict and makes resolution significantly easier. This is the highest-value setting most people do not have. rebase.autoSquash true, so fixup commits are handled automatically. rerere.enabled true, to reuse conflict resolutions. push.autoSetupRemote true on Git 2.37 and later, removing the need for -u. diff.algorithm histogram, which often produces more readable diffs than the default. And a handful of aliases for the commands you type constantly — particularly a log format you find readable, since the default is not.
How do you handle a repository with an inconsistent commit history?
Accept the past and fix forward, because rewriting a long shared history is disruptive and rarely worth it. Establish the convention going forward: a documented commit message format, and enforcement through a commit-msg hook managed by a tool that is installed from the repository, plus a CI check so it is not bypassable. For the mess already there, the practical measures are cosmetic rather than structural. A .mailmap normalises author names and emails at display time without touching history. A .git-blame-ignore-revs file lists bulk reformatting commits so blame skips them, which recovers most of blame's usefulness. If the history contains genuine problems rather than untidiness — a leaked secret, an enormous binary — those justify a rewrite, and you do it once and coordinate it properly. The thing not to do is rewrite history for aesthetics. Every hash changes, every clone breaks, every open pull request breaks, and every link to a commit in an issue or a document goes dead. The tidiness gained is not worth that. The honest framing is that history is a record, and records are allowed to be imperfect.
What is the difference between CI on a branch and CI on the merge result?
Testing the branch tests your code against the base it was created from. Testing the merge result tests it against the current base. The difference matters because main moves. Your branch may pass every test while another change merged after you branched breaks the combination — semantic conflicts that Git merges cleanly because they touch different lines, but that do not work together. A renamed method and a new caller of the old name is the classic example. Most platforms test the merge result by default for pull requests, constructing a temporary merge commit. That catches textual conflicts and tests the actual combination. What it does not catch is a change merged to main between your CI run and your merge. Requiring the branch to be up to date before merging closes that gap, at the cost of everyone rebasing constantly when the branch is busy. Merge queues solve it properly: changes are queued, tested against the actual resulting state in order, and merged only if they pass. That gives the guarantee without the rebase treadmill, and is why GitHub and others added them. For a low-traffic repository, up-to-date checks are sufficient.
How should a team decide on a branching strategy?
Work backwards from the release model, because the branching strategy exists to serve it. If you deploy continuously and every merge to main can go to production, you need almost no branching: main plus short-lived feature branches, or trunk-based with flags. Anything more is ceremony. If you ship versioned releases that must be stabilised while development continues, you need a release branch — and possibly a develop branch, which is where Git Flow earns its keep. If you support several versions in production simultaneously, you need long-lived maintenance branches and a deliberate strategy for backporting fixes. The other input is team size and trust. A small team with strong tests can commit to main directly. A large or distributed team, or one with external contributors, needs pull requests and protection rules. The practical advice is to start simpler than you think you need and add structure when a specific problem appears. Teams routinely adopt Git Flow by default and then suffer its overhead without ever having the release model it was designed for. And whatever you choose, write it down — an undocumented convention is not one.
What can you do with git log that most people do not?
The filtering options are where the value is. git log -S searches for commits that changed the number of occurrences of a string — the pickaxe. That is how you find when a function was introduced or removed, which grep on the current code cannot tell you. -G takes a regex and matches the diff itself. git log --follow tracks a file across renames. git log -L traces the history of a specific line range in a file, which is the fastest way to understand how one function evolved. git log branch-a..branch-b shows commits on b not on a — the basis of "what will this pull request add". The three-dot form shows the symmetric difference. --author, --since, --until, --grep filter the obvious ways. --first-parent on a merge-heavy history follows only the mainline, hiding the internals of merged branches, which makes main's history readable. And --format lets you produce exactly the output you want, which is how you build a readable log alias rather than tolerating the default.
What is the pickaxe search and when is it the right tool?
git log -S searches history for commits where the count of a given string changed — where it was added or removed. The reason it matters is that it answers a question nothing else can. Grepping the current codebase tells you where something is now. The pickaxe tells you when it appeared, when it disappeared, and in which commit — including code that no longer exists. The cases where it is the right tool: finding when a configuration value was introduced, tracing when a function was deleted and why, locating the commit that removed a check you now suspect was load-bearing, and investigating when a credential first entered the repository. Add --pickaxe-regex to treat the argument as a regular expression, or use -G which matches the diff text with a regex and reports any commit whose diff mentions it — subtly different, since -G matches even if the occurrence count did not change. Combine with -- pathspec to limit the search, since scanning a large history is slow. It is the single most useful Git feature that most engineers have never used, and it turns archaeology from guesswork into a query.
What does git diff show by default and how do you get the diff you want?
Bare git diff shows unstaged changes — working directory against index. That surprises people who expect it to show everything they have done. git diff --staged, or --cached, shows staged changes against HEAD. git diff HEAD shows both combined. For comparing commits, git diff a..b shows the difference between two points. The three-dot form git diff a...b shows what b added since diverging from a, which is what a pull request displays and is usually what you actually want when comparing branches. The options worth knowing: -w ignores whitespace, which removes reformatting noise. --stat gives a summary rather than the full diff. --name-only lists changed files. -- pathspec limits to specific paths. --word-diff is valuable for prose and configuration, showing changes within a line rather than marking the whole line changed. And the diff algorithm matters more than people expect: --histogram or --patience often produce far more sensible output than the default myers on code that has been reordered, and setting diff.algorithm histogram globally is a cheap improvement.
What is git add -p and why is it worth using?
It stages changes hunk by hunk rather than whole files, walking through the diff and asking what to include. The reason it matters is commit quality. Real work rarely produces neatly separated changes — you fix a bug, notice a typo, tidy a variable name, all in one file. Staging the whole file commits them together, which makes review harder and makes reverting one thing impossible. add -p lets you commit each logical change separately without having to have worked in that order. The options within it: y and n to stage or skip, s to split a hunk into smaller ones, e to edit the staged version by hand for cases where the split is not on hunk boundaries, and q to stop. The editing option is the powerful one and the least known — it lets you stage part of a line-level change. The secondary benefit is that it forces you to read your own diff before committing, which catches debugging statements, commented-out code and accidental changes remarkably often. git checkout -p, git reset -p and git stash -p work the same way for their operations.
What are Git attributes used for?
A .gitattributes file sets per-path behaviour, and it is committed so it applies to everyone — which is its advantage over configuration. The most valuable use is line ending normalisation. text=auto tells Git to store LF in the repository and check out the platform convention, which eliminates the CRLF conflicts that plague mixed Windows and Unix teams. Relying on each person configuring core.autocrlf correctly does not work. Marking files binary prevents Git attempting to diff or merge them, which produces meaningless output and corrupt merges. Custom diff drivers give useful diffs for non-text formats, and Git ships with several — a driver for a language makes hunk headers show the enclosing function, which makes diffs far more readable. Merge drivers define how to combine specific files, which is how you handle a file that should always take one side. export-ignore excludes paths from archives, so tests and CI configuration do not ship in a release tarball. And filters power LFS and can implement keyword expansion. The line ending setting alone justifies having the file in any cross-platform project.
What is sparse-checkout and when do you need it?
Sparse-checkout limits which paths appear in your working directory, while the repository still contains everything. The use is large monorepos. A repository with a hundred projects means checking out a hundred projects, so every status and every build tool scan touches files you never look at. Sparse-checkout lets you materialise only the directories you work on. The cone mode, added later, is the practical version: you specify directories rather than arbitrary patterns, which is much faster because Git can reason about whole subtrees rather than matching every path. It pairs naturally with partial clone. --filter=blob:none avoids downloading file contents you never check out, and sparse-checkout avoids writing them to disk. Together they make a very large repository usable. The things to be aware of: commits still contain everything, so a commit touching a path outside your sparse set is fine and invisible to you. Switching to a branch that needs paths outside your set requires updating it. And tooling that expects the whole tree can behave oddly. For a normal-sized repository it is unnecessary complexity.
What is git notes and why is it rarely used?
Notes attach arbitrary text to a commit without changing the commit, so its hash is unaffected. They are stored in a separate ref and displayed by git log. The appeal is adding information after the fact — a review link, a test result, a bug reference discovered later — to an immutable object. It is rarely used for practical reasons. Notes are not fetched or pushed by default; you must configure the refspec explicitly, so they are invisible to everyone until someone sets that up. Most hosting platforms do not display them. They conflict awkwardly when two people add notes to the same commit. And the tooling around them is thin. The result is that the information ends up in pull request comments, issue trackers and CI systems instead, which are visible by default. Where notes are genuinely used: Gerrit stores review metadata in them, and some CI systems attach build results. The reason to know about them in an interview is mostly conceptual — they demonstrate that Git's ref namespace is general, and that you can store arbitrary data alongside history without touching the commit graph.
How does git bisect run work?
You give it a script, and it performs the entire binary search automatically. After marking a known bad and a known good commit, git bisect run ./test.sh checks out each midpoint, runs the script, and interprets the exit code: zero means good, anything from 1 to 127 except 125 means bad, and 125 means skip this commit as untestable. That 125 convention is what handles commits that do not build — the script detects a build failure and exits 125, and bisect skips rather than reporting a false result. It turns an hour of manual checkout-and-test into a couple of minutes, and it is the thing that makes bisect practical rather than theoretical. Most people know bisect exists and have never automated it. The requirements: a reliable, fast reproduction expressible as a script, and a history where enough commits are testable. The script can be a single test invocation, a curl against a started server, or a grep of output — anything that decides. And git bisect reset returns you to where you started afterwards, which is easy to forget after the excitement of finding the commit.
What is a refspec?
A refspec describes how references map between a remote and your local repository, in the form source:destination with an optional leading plus meaning allow non-fast-forward updates. The default fetch refspec, written into config on clone, is +refs/heads/*:refs/remotes/origin/*, which says take every branch on the remote and store it under refs/remotes/origin. That is why remote branches appear as origin/whatever rather than as local branches. Understanding refspecs explains several things. Pushing to a differently-named branch is git push origin local:remote. Deleting a remote branch is pushing an empty source: git push origin :branch, which is why that odd syntax works. Fetching notes, or pull request refs on GitHub, requires adding a refspec because they are not covered by the default. And fetching only one branch, or mapping a remote branch to a differently-named local ref, is a refspec change. Most people never write one explicitly, which is fine — but knowing the concept turns several pieces of apparently arbitrary syntax into consequences of one rule, and it is what you need when configuring a mirror or a partial fetch.
How do you find a commit when you only remember roughly what it did?
Several search axes, and combining them narrows quickly. git log --grep searches commit messages. Add -i for case-insensitivity and --all to search every branch rather than the current one. git log -S searches for a code string appearing or disappearing, which works even if the message was uninformative — and messages usually are. git log --author narrows by who, and --since and --until by when. git log -- path restricts to files, which is often the fastest filter if you know roughly where the change was. git log -L traces a specific line range through history. If the commit is not reachable from any branch — it was on a deleted branch, or orphaned by a rebase — git reflog covers your local history, and git fsck --lost-found finds unreachable objects. The combination that solves most cases is a path restriction plus a pickaxe search, because you usually remember what file was involved and roughly what the code looked like even when you cannot remember the message or the date.
What is the difference between two-dot and three-dot notation?
They mean different things in log and in diff, which is the source of most of the confusion. In git log, a..b means commits reachable from b but not from a — what b has that a does not. That is the standard way to ask "what would this branch add". a...b is the symmetric difference: commits in either but not both. In git diff, the meanings differ. a..b is the plain difference between the two endpoints, comparing the two trees directly. a...b compares b against the merge base of a and b — that is, it shows what b changed since the branches diverged, ignoring anything a did afterwards. The three-dot diff is almost always what you want when reviewing a branch, and it is what pull requests display. The two-dot diff includes changes that main made and your branch does not have, presented as though you removed them, which is confusing. So the practical rule: three dots for diffing branches, two dots for logging what a branch adds. Remembering that they swap between the commands is the awkward part.
What are some Git aliases worth setting up?
The ones that pay for themselves are those replacing commands you type many times a day or that are hard to remember. A readable log format is the highest value: a one-line graph with abbreviated hash, relative date, author and refs. The default git log is verbose and the graph is unreadable without formatting, and most people simply tolerate it. An alias for the current-branch push with lease — force-pushing safely without typing the flag. One for undoing the last commit while keeping changes, which is git reset --soft HEAD~1 and is needed constantly. One for the staged diff, since git diff --staged is more common than bare git diff for many people. An alias for listing branches sorted by recent commit date, which makes finding what you were working on trivial. And shell aliases for the very short commands — status, checkout — since those are typed most. The caution worth adding: aliases make you fast in your own environment and unable to work in someone else's. Knowing the underlying commands matters, particularly in an interview where you may be asked what an alias actually does.
Git says your branch has diverged. What does that mean and what do you do?
Diverged means your local branch and its remote-tracking branch each have commits the other lacks — someone pushed while you had local commits. Git reports it as "ahead N, behind M". The options. Merge, with git pull, which creates a merge commit joining both. Safe and preserves everything, at the cost of a merge commit that is usually noise if this was just a routine pull. Rebase, with git pull --rebase, which replays your commits on top of the remote. Linear and cleaner, and appropriate when your commits are local and unpublished — which is the normal case. Or reset to the remote and discard your local commits, if they were a mistake. The decision hinges on whether your local commits have been pushed anywhere. If not, rebase freely. If they have, rebasing rewrites published commits and merging is safer. The preventive configuration is pull.rebase true or pull.ff only, so a routine pull either rebases or refuses rather than silently creating merge commits. And fetch first and look at git log HEAD..origin/main before deciding, rather than reflexively pulling.
Why might git status show a file as modified when you have not changed it?
Several causes, and identifying which saves a lot of confusion. Line endings are the most common: a CRLF file in a repository storing LF, or vice versa, so every line appears changed. git diff shows the whole file modified with no visible difference. The fix is a .gitattributes with text=auto and renormalising. File mode changes — the executable bit — are tracked by Git and differ across platforms and filesystems. core.fileMode false ignores them where that is appropriate. A tool touched the file: a formatter, a build step, or an IDE rewriting it on open. Smudge and clean filters, such as LFS or keyword expansion, transform files on checkout so the working copy legitimately differs from what is stored. A stale index stat cache can cause it after operations that touch timestamps, and git status resolves itself after re-hashing. The diagnostic is git diff to see what Git thinks changed. If the diff appears empty, it is line endings or mode. If it shows the whole file changed with identical content, it is definitely line endings.
Why is a file still tracked after adding it to .gitignore?
Because .gitignore only affects untracked files. Once a file is tracked, Git continues tracking it and reporting its changes regardless of any ignore rule. The fix is git rm --cached path, which removes it from the index while leaving it on disk, then committing that removal. From then on the ignore rule applies. For a directory, add -r. The consequence people miss is that this deletes the file for everyone else on their next pull, since the commit records a deletion. That is usually intended — you are removing a config file that should not have been shared — but it surprises colleagues who find their file gone. Telling them, or having them back it up first, avoids the support request. The related situation is a file that must be tracked but whose local changes should be ignored — a config template that everyone modifies. git update-index --skip-worktree is the intended mechanism, though it is local-only and has sharp edges. The cleaner design is to track a template and ignore the actual file. And git check-ignore -v tells you which rule is matching when the behaviour is unexpected.
What causes "refusing to merge unrelated histories"?
Git refuses to merge two branches with no common ancestor, because a three-way merge needs a merge base and there is none. It happens when you initialise a repository locally with commits, then add a remote that already has its own initial commit — so both have independent roots. It also happens when combining two projects that were developed separately. The override is --allow-unrelated-histories, which makes Git merge them by treating the empty tree as the base. Every file from both sides is added, and files present in both with different content conflict. The question worth asking before using it is whether merging is what you want. If you accidentally created a local repository and then cloned or added a remote, the usual intent is to start from the remote — so cloning fresh and copying your files in is cleaner than merging two roots and dealing with the conflicts. If you genuinely are combining two projects, the flag is correct, and doing it with the second project in a subdirectory — via subtree merge — usually produces a more sensible result than merging both at the root.
How do you deal with a repository that has become very slow?
Identify whether the problem is size, file count, or history. Run git count-objects -vH to see the object count and size. A large number of loose objects means gc has not run; git gc packs them. If the repository is genuinely large, the cause is usually large binaries in history. git rev-list with --objects piped through a sort by size, or a tool like git-sizer, finds them. Removing them requires filter-repo and coordination, or migrating to LFS. If status and checkout are slow but the repository is not large, the cause is file count. The filesystem monitor — core.fsmonitor — avoids scanning the whole tree, and untracked cache helps. On Windows, antivirus scanning the working directory is a frequent and dramatic cause. If history operations are slow, the commit-graph file speeds up traversal considerably and is enabled with fetch.writeCommitGraph. For a very large repository, partial clone and sparse-checkout are the structural answers. And check for pathological cases: an enormous number of refs, or a deeply nested submodule tree, both cause slowness that is not obvious from repository size.
Someone force-pushed and your work is gone. What do you do?
Do not pull, because pulling will reconcile your local branch with the new remote state and complicate things. Your commits are still in your local repository. git reflog shows where your branch pointed before, and the commits are reachable from there. Create a branch at your last good commit so it has a pointer and cannot be garbage collected: git branch my-work HEAD@{n}. Then decide how to reintegrate. If the force-push was legitimate — a rebase of a shared feature branch — rebase your work onto the new remote state. If it was a mistake, the person who did it should restore the branch, and their reflog has the previous tip. If your work was only on the remote and never local, you depend on someone else's reflog or the hosting platform, which often retains the commit and can restore it from the UI or via its API. The preventive measures are branch protection rejecting force pushes on shared branches, --force-with-lease as the team default, and pushing your work regularly so it exists in more than one place.
How do you debug a merge that produced broken code with no conflicts?
This is a semantic conflict: both sides changed different lines, so Git merged cleanly, but the combination does not work. Renaming a method on one branch while another adds a caller of the old name is the classic case. Git cannot detect these, because it merges text and has no understanding of meaning. Only compilation and tests catch them, which is why CI must test the merge result rather than the branch. To debug, first confirm it is the merge rather than either side: check out each parent and verify both work individually. git log --merge and git diff during a conflict do not apply here since there was no conflict, so the tools are ordinary — compile, read the error, and look at what each side changed in that area. git diff HEAD^1 HEAD and git diff HEAD^2 HEAD show what the merge brought in from each side, which usually makes the interaction obvious. The preventions are structural: test the merge result in CI, require branches to be up to date before merging, or use a merge queue that tests the actual resulting state in order. And shorter-lived branches, since divergence is what creates the opportunity.
What is the mental model that makes Git make sense?
Three ideas explain almost everything. First, commits are immutable snapshots forming a directed graph. Nothing edits a commit; operations that appear to — amend, rebase, cherry-pick — create new commits. That is why hashes change and why rewriting published history is disruptive. Second, branches and tags are just pointers into that graph, and HEAD is a pointer to where you are. Creating a branch is writing a file; deleting one removes a pointer and leaves the commits. Fast-forward is moving a pointer. Detached HEAD is pointing at a commit instead of a branch. Third, there are three trees — HEAD, the index, and the working directory — and every command moves data between them. reset's three modes, the difference between staged and unstaged, and why git diff shows what it does all follow from this. Once those are in place, the commands stop being arbitrary incantations. You can reason about what an unfamiliar command will do, and more importantly you can reason about how to recover, because you know that commits persist and the reflog remembers where pointers have been. Almost everything is recoverable except uncommitted work.