Git: Your branch and 'origin/main' have diverged (Fix)
Fix 'Your branch and origin/main have diverged' and fatal: Need to specify how to reconcile divergent branches: merge, rebase or ff-only.
Version control and delivery: Git internals, branching strategies, merge vs rebase, resolving conflicts, and CI/CD pipelines and deployment strategies.
Official reference: Git docs & Pro Git
Fix 'Your branch and origin/main have diverged' and fatal: Need to specify how to reconcile divergent branches: merge, rebase or ff-only.
Fix Git's error: failed to push some refs and ! [rejected] main -> main (fetch first): fetch, rebase or merge, and force-with-lease safely.
Fix git@github.com: Permission denied (publickey) and Could not read from remote repository: test SSH, load keys, set IdentityFile, or use HTTPS.
Fix Git's fatal: refusing to merge unrelated histories: why it happens, when --allow-unrelated-histories is right, and safer alternatives.
How to undo the last commit in Git: git reset --soft, --mixed or --hard, commit --amend, git revert for pushed commits, and reflog recovery.
Find the commit that broke a test with git bisect run, why unbuildable commits need exit 125, and how flaky checks blame the wrong commit.
GitFlow, GitHub Flow and trunk-based development compared by what matters: how long code waits before it meets everyone else's changes.
Rebuilding for each environment means production runs bytes nobody tested. Promote one artifact by digest and inject config at deploy time.
A canary is only as good as its pass/fail rule. Pick metrics, compare against a live baseline and wait for enough traffic before you promote.
Backport a fix to a release branch with git cherry-pick -x, handle the conflict when it depends on an earlier commit, and spot duplicate patches.
A green required check can hide a failed test. How pipes, skipped jobs and path filters fool branch protection, and how one gate job fixes it.
An atomic commit does one thing and still builds. Split mixed edits with git add -p, fold follow-up fixes in with --fixup, and revert precisely.
Resolve a Git merge conflict by working out what each side meant. Read the base with zdiff3, avoid the keep-both trap and test the combined rule.
Feature flags let you merge unfinished work and roll out slowly. Bucket users deterministically, give each flag an owner and expire it in CI.
A tracked file stays tracked after you add it to .gitignore. Untrack it with git rm --cached and warn teammates, whose pull deletes it.
Two pull requests can each pass CI and still break main together. A merge queue tests each one on top of everything queued ahead of it.
Merge joins histories; rebase rewrites your commits onto a new base. When to use each, and how a background fetch defeats --force-with-lease.
A CI cache should make builds faster, never different. Key it on every input, cache ~/.npm rather than node_modules, and use restore-keys warily.
Undo a bad reset --hard, restore a deleted branch or a dropped stash with git reflog and git fsck, and learn which lost work Git can't recover.
Derive release versions from annotated Git tags, stamp them into every artifact, and fix the shallow-clone CI failure that breaks git describe.
Reset moves a branch back; revert adds a commit that undoes one. Which is safe when, and the merge-revert trap that drops work on re-merge.
Rolling back code is easy; rolling back data isn't. Why a column rename breaks the last release, and how expand and contract keeps rollback safe.
Fork pull requests run code you didn't write. How GitHub Actions withholds secrets, and how pull_request_target and script injection leak them.
git stash is fine for five minutes and risky for five days: what it skips, why pop can leave a mess, and when a branch or worktree is better.
Git keeps three copies of each file: working tree, index and HEAD. Why a staged file can still show as modified, and what a commit really stores.
Git is a distributed version control system: a tool that records the history of your project in a repository on your own machine. Every clone holds the full history, so you can commit, branch, diff and read the log completely offline.
GitHub and GitLab are hosting platforms built around Git. They store a shared remote copy of the repository and add collaboration features that Git itself doesn't have:
Git doesn't depend on them at all: you can push to any server over SSH or HTTPS, or to a plain directory. A good way to say it: Git is the engine, GitHub and GitLab are services where teams park and review what the engine produces.
Likely follow-up: What does "distributed" mean compared with SVN? · What is the difference between a fork and a clone?
Git keeps your files in three places:
git add copies the current content of a file into it..git directory, holding every commit, tree and blob plus the refs that point at them. git commit turns the index into a new commit.The staging area lets you build a commit deliberately instead of committing everything that changed. If you fixed a bug and also refactored something unrelated, you can stage and commit them separately, even individual hunks with git add -p, which makes history easier to review, revert and bisect.
The matching diffs: git diff shows working tree vs index, and git diff --staged shows index vs the last commit.
.git stores commits, trees, blobs and refsgit add -p)Likely follow-up: What does git add -p let you do? · How do you unstage a file without losing the change?
git merge and git rebase, and when would you use each?midBoth bring changes from one branch into another, but they record history differently.
Merge joins two lines of history. If the target hasn't moved since you branched, Git just moves the pointer forward (a fast-forward). Otherwise it creates a merge commit with two parents. Nothing is rewritten, so it's always safe on shared branches, but history can become a tangle of merge bubbles.
Rebase takes your commits and replays them one by one on top of another base, creating new commits with new SHAs. The result is a linear history, as if you'd started from the latest main.
The golden rule: don't rebase commits that other people already have, such as a shared branch, because you'd rewrite history they built on.
In practice: rebase your own feature branch onto main to stay current and tidy it up before review; merge (or squash-merge via the PR) to integrate into shared branches.
git switch feature
git rebase main # replay feature's commits on top of main
git switch main
git merge feature # now just a fast-forward
git merge --no-ff feature # force a merge commit even if FF is possibleLikely follow-up: What does --no-ff do and why would a team want it? · What happens to conflicts during a rebase?
git fetch and git pull? What does git pull --rebase do?easygit fetch downloads new commits from a remote and updates your remote-tracking branches such as origin/main. It never touches your local branches or working tree, so it's always safe: you can inspect what changed before deciding what to do.
git pull is fetch plus integrate: after fetching, it merges the upstream branch into your current branch, or rebases onto it if configured. If your branch can simply fast-forward, it does that. If both sides have new commits and you haven't configured a preference, modern Git stops with "Need to specify how to reconcile divergent branches" and asks you to set pull.rebase or pull.ff.
git pull --rebase replays your local unpushed commits on top of the fetched upstream instead of creating a merge commit, which avoids noisy "Merge branch 'main' of ..." commits.
Also note: git status saying "up to date" only reflects your last fetch.
--rebase replays local commits, no merge commitLikely follow-up: What does pull.ff only do? · Why might git status say you are up to date when you are not?
git reset --soft, --mixed and --hard. How is that different from git revert?midgit reset moves the current branch pointer to another commit. The mode decides what happens to the index and working tree:
--soft: only the branch moves; the undone changes stay staged. Handy for re-doing or combining the last few commits.--mixed (the default): the index is reset too, so changes remain in your files but unstaged.--hard: index and working tree are overwritten. Uncommitted work is lost; the dropped commits are recoverable only through the reflog.git revert doesn't move anything backwards. It creates a new commit that applies the inverse of an existing one, so history is preserved.
The rule of thumb: use reset on local commits nobody else has; use revert for anything already pushed to a shared branch, because rewriting shared history forces everyone else to recover.
git reset --soft HEAD~1 # undo commit, keep changes staged
git reset HEAD~1 # --mixed (default): keep changes, unstaged
git reset --hard HEAD~1 # drop the commit AND the changes
git revert abc1234 # new commit that undoes abc1234Likely follow-up: How would you recover from an accidental reset --hard? · How do you revert a merge commit?
A conflict happens when both sides changed the same lines (or one side edited a file the other deleted) and Git can't decide automatically.
git status to see the files marked "both modified".<<<<<<< and ======= is your side (HEAD), and between ======= and >>>>>>> is the incoming side. With merge.conflictStyle set to zdiff3 (or diff3), Git also shows the common ancestor, which makes the intent of each side much clearer.git checkout --ours or --theirs.git add each file to mark it resolved, run the tests, then git commit (or git rebase --continue).If it goes wrong, git merge --abort returns you to the state before the merge. Prevention is better: small PRs and frequent integration keep conflicts small.
<<<<<<< HEAD
greeting = "hey"
||||||| be0d1df
greeting = "hi"
=======
greeting = "hello"
>>>>>>> featuregit status lists conflicted filesgit add marks resolved, then commit or continuegit merge --abort bails out safelyLikely follow-up: What do "ours" and "theirs" mean during a rebase? · What is git rerere?
git stash do, and what are its gotchas?easygit stash saves your uncommitted changes, both staged and unstaged, onto a stack and resets the working tree to HEAD. It's useful when you need a clean tree to switch branches, pull or check something quickly without making a throwaway commit.
git stash pop reapplies the latest entry and drops it; git stash apply reapplies but keeps it.git stash list shows entries as stash@{0}, stash@{1} and so on; -m gives them a readable message.Gotchas:
-u (or -a to include ignored files too).pop hits a conflict, the entry is not dropped; resolve it and git stash drop yourself.-uLikely follow-up: How do you turn a stash into a branch?
HEAD?easyA branch is just a named, movable pointer to a commit: a ref under refs/heads/ that stores one commit SHA. That's why creating a branch is instant and cheap; Git doesn't copy any files. When you commit on a branch, Git creates the new commit and moves that branch's pointer to it.
HEAD is Git's pointer to what you currently have checked out. Normally it's a symbolic ref to a branch (ref: refs/heads/main), so new commits advance that branch. If HEAD points directly at a commit instead, you're in detached HEAD state.
Useful notation: HEAD~1 (or HEAD~) is the first parent of HEAD, HEAD~3 goes three generations back along first parents, and HEAD^2 is the second parent of a merge commit.
Tags are also refs, but they're meant to stay fixed, while branches move.
~ walks first parents, ^2 is a merge's second parentLikely follow-up: What happens to commits made in detached HEAD state?
They're three increasing levels of automation.
Continuous integration (CI): developers merge small changes into the main branch frequently, at least daily, and every push or pull request triggers an automated build and test run. The goal is fast feedback and a main branch that is always green.
Continuous delivery: every change that passes the pipeline produces an artifact that is ready to release and has been deployed to production-like environments. Going to production is a push-button business decision, often with an approval step.
Continuous deployment: there's no manual gate. Every change that passes all the automated checks is deployed to production automatically.
Continuous deployment only works with strong automated tests, good monitoring and alerting, feature flags to hide unfinished work, and fast, reliable rollback. Many teams practise continuous delivery and keep a human approval for production.
Likely follow-up: What would stop you from moving a team to continuous deployment?
git commit --amend replaces the last commit with a new one built from the current index. Stage the forgotten file, then amend; --no-edit keeps the message, or -m rewrites it. If you want to undo the commit completely but keep the work, git reset --soft HEAD~1.
Amending doesn't edit the old commit, it creates a new commit with a new SHA. The old one is still reachable through the reflog for a while.
If it's not pushed, that's the end of it. If it is pushed, your local branch and the remote have now diverged, so publishing the fix needs a force push, ideally git push --force-with-lease. That's fine on your own feature branch, but on a shared branch like main don't rewrite history: make a follow-up commit or a git revert instead.
--amend replaces the last commitreset --soft HEAD~1 undoes but keeps changes--force-with-lease, only on your own branchLikely follow-up: How would you fix a commit three commits back?
There are two separate kinds of local change:
git restore --staged --worktree . resets both the index and the files to HEAD; git reset --hard does the same. Plain git restore . only discards unstaged changes.git clean deletes them: -f is required to actually delete, -d includes directories, and -x also removes ignored files, which may include your .env or local config.Always run git clean -n (dry run) first, because this is one of the few truly destructive operations: discarded uncommitted work isn't in the reflog and can't be recovered.
If you might want the changes later, git stash -u is the safer option: it clears the working tree but keeps everything, including untracked files, in the stash.
git status --short # see what you're about to lose
git restore --staged --worktree . # tracked files back to HEAD
git clean -n -d # dry run: untracked files and dirs
git clean -f -d # delete them
git clean -f -d -x # also delete ignored files (build output, .env!)restore --staged --worktree . or reset --hard for tracked filesgit clean -fd removes untracked files and dirs-x also deletes ignored files; beware .envgit clean -n firstgit stash -u if you might need it latergit cherry-pick do, and when is it the right tool?midgit cherry-pick takes the changes introduced by an existing commit and applies them as a new commit on your current branch. The new commit has the same diff and message but a different parent, so it gets a different SHA.
Typical uses:
main to a release branch.Useful options: -x appends "(cherry picked from commit ...)" to the message, which is great for traceability on release branches; -n applies without committing; a range A..B picks everything after A up to B; -m 1 is needed to pick a merge commit. Conflicts are resolved like a merge, then git cherry-pick --continue.
It's not a replacement for merging: if you need most of a branch, merge it. Heavy cherry-picking duplicates commits across branches and makes it hard to tell what has really been shipped where.
-x records the original commitLikely follow-up: Why does Git sometimes skip a cherry-picked commit during a later rebase?
Git Flow uses two long-lived branches, main (released code) and develop (integration), plus feature/*, release/* and hotfix/* branches. It suits software with versioned, scheduled releases or several supported versions, like installed apps or libraries. For a web service it's heavy: long-lived branches mean big merges and slow feedback.
GitHub Flow has one long-lived branch, main, which is always deployable. You create a short-lived branch, open a pull request, CI runs, it gets reviewed, merged and deployed. Simple and a good default for most web teams.
Trunk-based development goes further: everyone integrates into trunk at least daily, via very short-lived branches or direct commits, and unfinished work is hidden behind feature flags. Release branches, if any, are cut from trunk. It's what high-performing CI/CD teams tend to use, but it demands good automated tests and discipline.
I'd pick GitHub Flow or trunk-based for a continuously deployed service, and Git Flow only when release management genuinely requires it.
Likely follow-up: How do you handle an urgent hotfix in trunk-based development? · What goes wrong with long-lived feature branches?
.env to .gitignore, but Git still tracks it. Why, and how do you fix it?easy.gitignore only applies to untracked files. It stops new files from being picked up by git add and git status, but once a file is tracked, Git keeps tracking changes to it regardless of ignore rules.
To fix it, add the pattern and remove the file from the index only with git rm --cached .env (-r for a directory), then commit. The file stays on your disk and is now ignored.
Two gotchas:
git check-ignore -v path tells you which rule ignores a file. For personal ignores that shouldn't be committed, use .git/info/exclude or a global excludes file.
echo ".env" >> .gitignore
git rm --cached .env # stop tracking, keep the file on disk
git commit -m "Stop tracking .env"
git check-ignore -v .env # .gitignore:1:.env .envgit rm --cached untracks but keeps the filegit check-ignore -v explains which rule matchedLikely follow-up: How would you remove a leaked secret from the whole history?
A squash merge combines all of a branch's changes into one new commit on the target. Locally, git merge --squash feature stages the combined result without committing; you then commit it. It isn't a real merge: the new commit has only one parent, so Git doesn't record that feature was merged. On GitHub or GitLab it's the "Squash and merge" button.
Pros: one tidy commit per pull request, a linear and readable main, and reverting a whole feature is a single git revert. Messy "fix typo" commits disappear.
Cons: the individual commits are lost from main (less useful for bisect and blame on large PRs), and because Git can't see the branch as merged, git branch -d refuses to delete it. If you keep committing on that branch and merge again, you'll get duplicate changes or conflicts.
A merge commit keeps every commit plus the branch structure; rebase-and-merge keeps every commit but linearly.
Likely follow-up: Which merge method would you enforce on a team, and why?
git reset --hard and lost commits. How do you get them back?midUse the reflog. Every time HEAD or a branch tip moves (commit, reset, rebase, checkout, amend), Git records the old and new position in a local log. Commits that no branch points to still exist in the object database, and the reflog tells you their SHAs.
Run git reflog, find the entry just before the mistake, and either create a branch there (git branch rescue <sha>) or move your branch back with git reset --hard HEAD@{1}. ORIG_HEAD also holds the previous tip after a reset, merge or rebase.
Limits worth mentioning:
git gc can prune the objects.reset --hard were never commits, so the reflog can't help. Previously staged content may survive as dangling blobs (git fsck --lost-found), but that's a long shot.git reflog
# 14424d6 HEAD@{0}: reset: moving to HEAD~1
# 88bc598 HEAD@{1}: commit: Add payment retries
git branch rescue 88bc598 # safest: put a branch on it
git reset --hard HEAD@{1} # or move the current branch backLikely follow-up: How would you recover a branch you deleted with git branch -D?
Normally HEAD points to a branch, and the branch points to a commit. In detached HEAD state, HEAD points directly at a commit. You get there by checking out a SHA, a tag or a remote-tracking branch (git checkout origin/main), and Git itself detaches HEAD during rebase and bisect. Many CI systems also check out a specific commit rather than a branch.
It's perfectly fine for looking around, building an old version or testing. You can even commit, but no branch moves to include those commits. When you switch away, they're left unreachable; Git warns "you are leaving 1 commit behind" and prints the SHA. Eventually garbage collection deletes them.
To keep the work, create a branch while you're still there, git switch -c name. If you already left, find the SHA in the warning or in git reflog and run git branch name <sha>.
git switch -cGit is a content-addressed object store. Every object is identified by a hash of its type, size and content: SHA-1 by default, with SHA-256 repositories available via git init --object-format=sha256.
Refs such as branches and tags are just human-friendly names that store a SHA.
So a commit is a snapshot, not a diff; unchanged files reuse the same blob. Because a commit's hash covers its tree and its parents' hashes, changing anything in history changes every descendant's SHA. That's why rewriting history produces new commits and why a SHA identifies an exact state.
git cat-file -p HEAD
# tree b4ed918248039b78f24383523fa4e51f80994fac
# parent 14424d6...
# author Ana <ana@example.com> 1790561526 +0530
git cat-file -p HEAD^{tree}
# 100644 blob ce013625030ba8dba906f756967f9e9ca394464a f.txt
echo hello | git hash-object --stdin # ce0136250...Likely follow-up: If commits are snapshots, why is a Git repo so small? · Why does amending a commit change its SHA?
A typical pipeline, triggered by a push or pull request:
Principles: fail fast, parallelise independent jobs, and build once, promote the same artifact through every environment instead of rebuilding, so what you tested is exactly what you ship.
Likely follow-up: Where would you run end-to-end tests, and why not on every commit?
A workflow is a YAML file in .github/workflows/.
on): events such as push, pull_request, workflow_dispatch (manual button), schedule (cron, UTC by default), release or workflow_call (reusable workflows). You can filter by branch, tag or path.needs creates dependencies and ordering. Jobs don't share a filesystem, so you pass files between them with artifacts.runs-on. GitHub-hosted runners (ubuntu-latest, windows-latest, macos-latest) give each job a fresh VM; self-hosted runners are your own machines.uses an action (reusable code like actions/checkout) with inputs in with, or runs shell commands.Other common keys: env, permissions for the GITHUB_TOKEN, strategy.matrix, environment and concurrency.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm test.github/workflows/on defines triggers: push, pull_request, schedule...needs orders themuses actions or run commandsLikely follow-up: How do two jobs share a build output? · How would you stop two deployments running at once?
--force-with-lease over --force?midAfter a rebase or amend, your local branch no longer contains the remote tip, so a normal push is rejected as non-fast-forward. You have to force it.
--force overwrites the remote branch with whatever you have, no questions asked. If a teammate pushed a commit since you last fetched, it silently disappears.
--force-with-lease only overwrites the remote ref if it still points where your remote-tracking branch says it does, the state you last saw. If someone pushed in the meantime, the push fails and you can fetch and incorporate their work. The caveat: if your IDE fetches in the background, your tracking ref is already updated and the lease protects nothing. --force-if-includes adds a check that the remote tip is actually in your local branch's history.
Team rules: only force-push your own feature branches, protect main so force pushes are blocked, and tell anyone else on the branch so they can reset onto the new history.
--force can silently delete others' commits--force-if-includes helpsLikely follow-up: What should a teammate do after you force-pushed a branch they have checked out?
With an interactive rebase: git rebase -i main (or HEAD~5) opens a todo list with one line per commit, oldest first. You edit the list and Git replays it:
pick keeps a commit, reword changes its message, edit stops so you can amend or split it.squash melds a commit into the previous one and combines messages; fixup does the same but discards its message.drop (or deleting the line) removes a commit; reordering lines reorders commits.exec runs a command, for example the tests, after a commit.A nice workflow is to make corrections as git commit --fixup=<sha> while you work, then git rebase -i --autosquash main moves each fixup under its target automatically.
If it goes wrong, git rebase --abort restores the branch, and the reflog has the old tip. Because it rewrites commits, do it only on your own branch, then publish with --force-with-lease.
git commit --fixup=5dc6f3f # "fixup! add parser" commit
git rebase -i --autosquash main
# editor opens the todo list:
# pick 5dc6f3f add parser
# fixup 31af573 fixup! add parser
# reword cd4a1d2 add tests
# drop 9e1b77a debug logginggit rebase -i edits a todo list of commits--fixup plus --autosquash automates squashing--abort or reflog to undoLikely follow-up: How would you split one commit into two?
origin and upstream differ?easyA remote is a named URL of another copy of the repository. git clone creates one called origin for the place you cloned from. In a fork workflow, people conventionally add upstream for the original project, so they pull from upstream and push to their fork, origin. The names are only conventions.
A remote-tracking branch like origin/main is your local, read-only snapshot of where that branch was on the remote at your last fetch. You don't commit to it; git fetch updates it.
A local branch can have an upstream (tracking) branch configured. That's what makes plain git pull and git push know where to go and lets git status report "ahead 2, behind 1". Set it with git push -u origin feature or git branch --set-upstream-to=origin/feature.
git fetch --prune removes tracking refs for branches deleted on the remote.
origin created by cloneupstream conventionally the original repo of a forkorigin/main is a cached snapshot from last fetchpush -u sets the upstreamA clone is a Git operation: git clone copies a repository, with its full history, onto your machine.
A fork is a hosting-platform feature: GitHub or GitLab creates a server-side copy of someone else's repository under your account, remembering which project it came from. Forks exist so you can contribute to a project you don't have write access to.
The usual open-source workflow:
origin.upstream.upstream and rebasing or merging its main, or with the platform's "sync fork" button.Inside a company, where developers usually have write access, people normally skip forks and push branches to the shared repository.
origin is your fork, upstream the originalRolling: replace instances of the old version with the new one a few at a time, keeping the service up. It needs little extra capacity and is the default for a Kubernetes Deployment. The downsides: old and new versions serve traffic side by side for a while, and rolling back means rolling through all instances again.
Blue-green: run two identical environments. Blue serves production while you deploy and test green; then you switch all traffic at the load balancer or DNS. Rollback is nearly instant: switch back. It costs double capacity during the switch, and the database must work with both versions.
Canary: send a small slice of real traffic, say 1–5%, to the new version, compare error rates and latency with the baseline, then increase step by step, ideally with automated analysis that aborts on regression. It has the smallest blast radius, but needs traffic splitting and good observability.
I'd default to rolling for low-risk services, canary for high-traffic, business-critical ones, and blue-green where instant cutover and rollback matter most.
Likely follow-up: How do feature flags differ from a canary release? · How do database migrations fit into blue-green?
As the author:
As the reviewer:
Enforce the basics with branch protection: required reviews, required status checks, and CODEOWNERS for sensitive areas.
Likely follow-up: How do you handle a disagreement in code review?
A good commit is atomic: one logical change, complete on its own, with the tests passing. That makes history easy to review, revert, cherry-pick and bisect. Mixing a bug fix, a refactor and a formatting pass in one commit makes all of those harder.
A good message:
git log --oneline, PR lists and tools show.BREAKING CHANGE note if relevant.Many teams add a Conventional Commits prefix such as fix(auth): so changelogs and version bumps can be automated. Avoid "WIP" and "misc" commits on shared branches; clean them up with an interactive rebase or a squash merge.
git diff, git diff --staged, git diff HEAD and git diff main...feature each show?easyThey compare different pairs of snapshots:
git diff: working tree against the index, so only changes you haven't staged.git diff --staged (same as --cached): the index against HEAD, exactly what the next commit will contain.git diff HEAD: working tree against the last commit, staged and unstaged together.git diff main feature (or main..feature): the two tips directly. If main moved on, its new commits show up as reversed changes.git diff main...feature: feature against the merge base of the two, which is only what the feature branch changed. That's what a pull request shows.Handy flags: --stat or --name-only for a summary, --word-diff for prose, -w to ignore whitespace.
Careful: in git log, the dots mean something different. main..feature lists commits on feature not on main, and ... is the symmetric difference.
git diff # working tree vs index (unstaged changes)
git diff --staged # index vs HEAD (what you're about to commit)
git diff HEAD # working tree vs HEAD (all local changes)
git diff main feature # snapshot of main vs snapshot of feature
git diff main...feature # feature's changes since it branched off main
git diff --stat HEAD~3 # summary: files and line counts--staged: what will be committeddiff HEAD: all local changeslog vs diffgit switch and git restore added when git checkout already existed?easygit checkout does two unrelated jobs: switching branches and overwriting files from the index or a commit. The same command name for both is confusing, and it's dangerous: git checkout -- file silently throws away your uncommitted changes, and git checkout name behaves differently depending on whether name is a branch or a file.
Git 2.23 split it into two focused commands:
git switch only changes branches: -c creates one, - returns to the previous branch, --detach checks out a commit.git restore only restores files: by default the working tree from the index, --staged to unstage, --source=<commit> to take a file from another commit.checkout still works and you'll see it everywhere in older docs and scripts, but switch and restore make intent explicit and are harder to misuse.
git switch main # was: git checkout main
git switch -c feature # was: git checkout -b feature
git switch - # back to the previous branch
git restore app.js # discard unstaged changes to app.js
git restore --staged app.js # unstage, keep the change
git restore --source=HEAD~2 app.js # bring back an older versioncheckout mixed branch switching and file restoringswitch handles branches onlyrestore handles files: worktree or --stagedrestore discards changes, so use it deliberatelycheckout still works; new commands are clearerRenaming: git branch -m new-name renames the current branch, or git branch -m old new renames another one. Git has no "rename" on a remote, so you push the branch under the new name with git push -u origin new-name (which also updates the upstream) and delete the old remote branch. Open pull requests tied to the old name may need attention, and teammates must update their tracking branches.
Deleting locally: git branch -d feature only succeeds if the branch is fully merged: into its upstream, or into the current branch if it has none. That safety check fails after a squash merge, because Git can't see the commits in main. git branch -D forces it; the commits are then reachable only through the reflog.
Deleting on the remote: git push origin --delete feature. Other clones keep a stale origin/feature until they run git fetch --prune. Most platforms can delete the branch automatically after a PR is merged.
git branch -m new-name # rename the current branch
git branch -m old-name new-name # rename another branch
git push -u origin new-name # publish under the new name
git push origin --delete old-name # remove the old remote branch
git branch -d feature # delete if fully merged
git branch -D feature # force delete (unmerged work!)git branch -m renames-d requires merged; -D forcesgit push origin --delete removes a remote branchgit fetch --prune clears stale tracking refsI'd use git bisect, which does a binary search through history. Mark a known bad commit and a known good one, and Git checks out the commit halfway between. You test it and mark it good or bad, and each answer halves the range, so 300 commits take about 9 steps.
Better still, automate it with git bisect run <script>. The script's exit code drives the search: 0 means good, 1 to 127 (except 125) means bad, and 125 means "can't test this one, skip it", for example when the build is broken. Anything else aborts the run. A failing test written for the bug is the perfect script.
git bisect skip handles untestable commits manually, and git bisect reset returns you to your original branch. You can also rename the terms, for example --term-old=fast --term-new=slow, when hunting a performance regression rather than a bug.
Bisect works best when every commit builds and is small, which is one more argument for clean history.
git bisect start
git bisect bad # current commit is broken
git bisect good v2.3.0 # this release was fine
# Git checks out the midpoint; test it, then mark good/bad...
git bisect run ./check.sh # or automate: exit 0 good, 1-127 bad, 125 skip
git bisect reset # return to where you startedbisect run: exit 0 good, 1-127 bad, 125 skipbisect reset when finishedLikely follow-up: How does bisect handle merge commits and branches?
git blame?easygit blame file annotates every line with the commit, author and date that last changed it. -L narrows it to a line range, -w ignores whitespace-only changes, and -M / -C detect lines moved within a file or copied from other files.
Blame tells you which commit; the useful part is then reading that commit with git show, its message and its pull request, to understand why. It's for context, not for blaming people.
Large formatting commits make every line point at "run prettier". List such commits in a file, conventionally .git-blame-ignore-revs, and pass it with --ignore-revs-file or set blame.ignoreRevsFile so blame looks past them. GitHub's blame view honours a .git-blame-ignore-revs file in the repository root.
When a line has been rewritten many times, git log -L shows the history of a line range, and git log -S finds commits that added or removed a string.
-L range, -w whitespace, -M/-C moves.git-blame-ignore-revsFirst restore service, then fix properly. The fastest options, roughly in order:
kubectl rollout undo.git revert the bad commit on main so the code matches what's running and the next deploy doesn't reintroduce the bug.Design for it:
Sometimes rolling forward with a small fix is faster, but only if you're confident.
git revert so main matches productionLikely follow-up: When would you roll forward instead of rolling back?
The core problem is that during a rolling, blue-green or canary deployment, old and new application versions run at the same time against the same database, and you may need to roll the application back. So every migration must be compatible with both the current and the previous version.
The standard technique is expand and contract (parallel change). To rename a column:
Other rules: never drop or rename in the same deploy as the code change; run migrations as a separate pipeline step with a versioned tool like Flyway, Liquibase or Alembic; avoid long table locks (for example, create indexes concurrently in PostgreSQL); and test against production-sized data. Rolling back usually means rolling the app back, not the schema, which is exactly why the schema must stay backward compatible.
Likely follow-up: How would you roll back a deployment that included a migration?
GITHUB_TOKEN permissions to read-only by default, and put production secrets behind an environment with required approval.pull_request_target should never run a PR's untrusted code with secrets. Pin third-party actions to a full commit SHA.Likely follow-up: How does OIDC authentication from GitHub Actions to a cloud provider work?
A matrix runs the same job once per combination of variables. With two operating systems and two Node versions you get four parallel jobs, each reading its values from matrix.os and matrix.node. include adds or extends combinations, exclude removes some, and max-parallel limits concurrency. fail-fast defaults to true, which cancels the remaining jobs when one fails; set it to false when you want the full picture. A matrix can generate at most 256 jobs per workflow run.
Caching saves directories like ~/.npm between runs. The key usually includes the OS and a hash of the lockfile, so a dependency change produces a new cache. On an exact key miss, restore-keys are tried as prefixes to restore the most recent close match, and the cache is saved under the new key at the end. Setup actions such as actions/setup-node offer this built in via cache: npm.
Caches are scoped by branch (a branch can use its own and the default branch's), unused entries are evicted after 7 days, and you should never cache secrets.
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [22, 24]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v7
with: { node-version: "${{ matrix.node }}" }
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: npm-${{ runner.os }}-include, exclude, max-parallel, fail-fastrestore-keys give prefix fallbacksLikely follow-up: What is the difference between a cache and an artifact?
A feature flag is a runtime switch that turns a code path on or off without a new deployment, usually read from a flag service or config.
Their key effect is to decouple deploying from releasing. Unfinished work can be merged into trunk every day behind a flag that's off, so there's no long-lived feature branch, and it deploys harmlessly with everything else. Releasing becomes a flag flip: to internal users first, then a percentage of customers, then everyone. If something goes wrong, turning the flag off is the fastest rollback there is.
Common kinds: short-lived release toggles, ops toggles and kill switches, experiment toggles for A/B tests, and long-lived permission toggles.
The costs are real: every flag doubles the code paths to test, and stale flags become technical debt, so give each one an owner and remove it once the rollout finishes. Define a safe default in case the flag service is unreachable.
Likely follow-up: How would you test code with several flags?
Semantic versioning uses MAJOR.MINOR.PATCH: bump MAJOR for incompatible API changes, MINOR for backward-compatible features, and PATCH for backward-compatible bug fixes. Versions below 1.0.0 mean initial development, where anything may change. Pre-releases look like 2.0.0-rc.1 and sort before 2.0.0; build metadata after a + is ignored for precedence.
Conventional Commits is a commit message convention: type(scope): description. feat maps to a MINOR bump, fix to a PATCH, and a breaking change, marked by ! before the colon or a BREAKING CHANGE: footer, maps to MAJOR. Other types such as docs, refactor, test, ci and chore are allowed and don't trigger a release on their own.
Because the messages are machine-readable, tools can generate changelogs and compute the next version automatically, and they're easy to enforce with a commit-msg hook or a CI check.
feat(cart): add coupon codes # -> MINOR
fix(auth): refresh expired tokens # -> PATCH
docs: explain rate limits # no release by itself
feat(api)!: remove v1 endpoints # ! -> MAJOR
BREAKING CHANGE: clients must call /v2 # footer also -> MAJORtype(scope): description message formatfeat minor, fix patch, ! or footer majorLikely follow-up: What does a 0.x version tell consumers?
Hooks are scripts Git runs at certain points. Client-side ones include pre-commit (lint and test before the commit is created), commit-msg (validate the message), pre-push, post-checkout and post-merge. Server-side hooks such as pre-receive run on the server during a push. For the pre- hooks, a non-zero exit aborts the operation.
By default they live in .git/hooks, which is not versioned or cloned, so every developer would have to install them manually. To share them:
.githooks and point core.hooksPath at it;Keep hooks fast, for example by checking only staged files. And remember they're a convenience, not enforcement: git commit --no-verify skips pre-commit and commit-msg. Anything that really matters must also run in CI.
#!/bin/sh
# .githooks/pre-commit (enable: git config core.hooksPath .githooks)
files=$(git diff --cached --name-only --diff-filter=ACM -- '*.js')
[ -z "$files" ] && exit 0
npx eslint $files || exit 1 # non-zero exit aborts the commit.git/hooks is not versioned or clonedcore.hooksPath or a hook manager--no-verify bypasses them; CI must enforceA flaky test passes and fails on the same code. It's dangerous beyond the wasted time: people learn to hit "re-run" and stop trusting red builds, so real failures slip through.
Detect: track test results over time, rerun failures to confirm flakiness, and measure a flake rate per test.
Contain: quarantine known-flaky tests out of the blocking suite, with a ticket, an owner and a deadline, so they don't block everyone but aren't forgotten.
Fix the root cause. The usual suspects:
Automatic retries are fine as a short-term safety net, but they can hide real race conditions in the product. Treat flakiness as a bug with priority, because a pipeline nobody trusts is worse than none.
DORA's research measures software delivery performance with a small set of metrics. The classic "four keys" are:
Recent DORA guidance adds a fifth, deployment rework rate: unplanned deployments made because of a production incident. DORA groups them into throughput (frequency, lead time, recovery time) and instability (change failure rate, rework rate), and the key finding is that they aren't a trade-off: top performers are both fast and stable, because small, frequent changes are easier to test and roll back.
Use them at the team or service level to spot bottlenecks and track improvement, measured from version control, the pipeline and incident data. Don't use them to rank individuals or set targets to game.
Likely follow-up: How would you actually measure lead time for changes?
Use an environment. In the repository settings, create production and add deployment protection rules, then reference it from the job with environment: production.
main or v* tags to deploy there.Secrets stored on the environment are only available to jobs that reference it, and only after approval, so the production credentials are gated too. Add concurrency so two production deploys never overlap, and needs so production only runs after staging succeeds. Availability of some protection rules for private repositories depends on your GitHub plan.
GitLab CI's equivalent is a job with environment, when: manual and protected environments.
deploy-prod:
needs: deploy-staging
runs-on: ubuntu-latest
environment:
name: production
url: https://shop.example.com
concurrency: production # one prod deploy at a time
steps:
- run: ./deploy.sh
env:
TOKEN: ${{ secrets.PROD_DEPLOY_TOKEN }} # environment secretenvironment: productionconcurrency prevents overlapping deploysneeds chains staging before productionAn artifact is the output of a build that you deploy or distribute: a JAR, a binary, a zipped static site, an npm package or a container image. The core principle is build once, deploy many: the same artifact that passed the tests is promoted through staging and production, never rebuilt per environment. Environment differences belong in configuration.
Artifacts live in a repository manager (Nexus, Artifactory, GitHub Packages, npm) and images in a container registry (GHCR, Docker Hub, Amazon ECR, Google Artifact Registry, GitLab's registry).
Good practice:
latest. For exactness, deploy by digest (@sha256:...).Don't confuse this with CI workflow artifacts (actions/upload-artifact), which pass files between jobs and expire after a retention period; they're not a release registry.
latestA merge commit has two parents, so git revert needs to know which side is the mainline: git revert -m 1 <merge> keeps the first parent, the branch you merged into, and undoes everything the merged branch brought in. Without -m, Git refuses.
The gotcha is that a revert undoes the changes, not the history. The feature's commits are still ancestors of main, so Git considers them merged. If the team fixes the feature and merges the branch again, Git only brings in the commits made after the first merge. If there are none, it says "Already up to date", and the original work stays reverted.
To re-land it, revert the revert first, which restores the original changes, then merge the new fixes. Alternatively rebuild the feature as fresh commits, for example by rebasing it onto current main so it gets new SHAs.
This is why many teams prefer squash merges or feature flags: undoing a feature is simpler.
git revert -m 1 3c06b58 # keep parent 1 (main), undo the branch's changes
git merge feature # Already up to date. (Git thinks it's merged)
git revert 62bff45 # revert the revert to bring the feature back-m 1 picks the mainline parentmain branch on GitHub or GitLab, and why not rely on local hooks?midLocal hooks can be skipped with --no-verify or simply never installed, so rules that matter have to be enforced on the server. On GitHub that's branch protection rules or rulesets; on GitLab, protected branches and merge request approval rules.
Typical settings for main:
main, or use a merge queue so each change is tested against the latest main.Together these make "main is always green and reviewed" a guarantee instead of a hope.
Likely follow-up: What problem does a merge queue solve?
Git keeps every version of every file forever, and every clone downloads the full history. Binaries such as images, videos, datasets or compiled builds don't diff or compress well, so each change adds roughly the full file size. The repository gets permanently slower to clone and fetch, and GitHub warns above 50 MiB and blocks files over 100 MiB.
Git LFS (Large File Storage) replaces large files in the repository with small pointer files containing the object's SHA-256 and size. The real content lives on an LFS server and is downloaded only for the commits you check out. You choose files by pattern with git lfs track, which writes rules to .gitattributes; commit that file.
Things to know: files already committed stay in history until you rewrite it with git lfs migrate import; hosts have LFS storage and bandwidth quotas; CI checkouts must fetch LFS content (for example lfs: true on actions/checkout); and git lfs lock helps with files that can't be merged. Build outputs belong in an artifact store, not in Git at all.
git lfs track writes .gitattributesgit lfs migrate importFirst, revoke or rotate the key immediately. Assume it's compromised: automated scanners pick up secrets on public repositories within minutes, and removing the commit doesn't un-leak it. Check the provider's logs for misuse.
Only then clean up history, if it's worth it:
git filter-repo, which Git's own documentation recommends over git filter-branch, or a tool like BFG: remove the file with --invert-paths --path, or replace the string with --replace-text.Then prevent a repeat: .gitignore the file, load secrets from environment or a vault, and enable secret scanning with push protection or a pre-commit scanner.
# 1. Revoke/rotate the key FIRST. Then, in a fresh clone:
git filter-repo --sensitive-data-removal --invert-paths --path config/keys.json
# or replace the value everywhere:
git filter-repo --sensitive-data-removal --replace-text ../expressions.txt
git push --force --mirror origingit filter-repo, force push all refsMonorepo: many projects in one repository.
git status get slow, every PR could trigger every build, and access control is coarse.Polyrepo: one repository per service or library.
Keeping a monorepo fast: partial clone (--filter=blob:none) and sparse checkout so developers only materialise what they work on; build tools that understand the dependency graph and run only affected builds and tests (Bazel, Nx, Turborepo), or CI path filters; and CODEOWNERS for ownership.
The deciding factor is coupling: tightly coupled code that changes together favours a monorepo.
Likely follow-up: How would you version shared libraries in a polyrepo setup?
A submodule embeds another repository as a pointer: the parent records a special tree entry (a gitlink) with the exact commit SHA of the child, plus its URL in .gitmodules. The child's files aren't stored in the parent. That gives exact pinning and independent history, but it's easy to get wrong: a plain clone leaves the directory empty until you run git submodule update --init --recursive (or clone with --recurse-submodules), the submodule sits in detached HEAD, and forgetting to push the child's commit breaks everyone's checkout.
A subtree copies the other project's files, optionally with its history squashed, into a subdirectory as ordinary commits. Clones just work and contributors need no extra commands; git subtree pull brings in upstream updates and git subtree push can send changes back. The cost is a bigger repo and manual syncing. git subtree ships in Git's contrib area, so check it's installed.
I'd use submodules for a large external dependency with its own release cycle, and subtrees when vendoring something small that consumers shouldn't have to think about. Often a package manager beats both.
.gitmodules--recurse-submodules or update --initInfrastructure as code (IaC) means defining infrastructure, such as networks, clusters, databases and permissions, in declarative files (Terraform or OpenTofu, CloudFormation, Pulumi) kept in Git. Changes go through pull requests like application code: CI runs a plan showing exactly what will change, a reviewer approves, and apply makes it so. You get reproducible environments, an audit trail and drift detection, but you must manage the tool's state carefully, with remote storage and locking.
GitOps applies the same idea to deployments. Git holds the desired state, for example Kubernetes manifests or Helm values, and an agent running in the cluster, such as Argo CD or Flux, continuously pulls that state and reconciles the live system to match it. Deploying is merging a PR that changes an image tag; rolling back is a git revert; manual changes in the cluster are detected as drift and reverted.
Compared with push-based CI, where the pipeline runs kubectl apply with cluster credentials, the pull model keeps credentials inside the cluster and makes Git the single source of truth.
Commit author details are just text: anyone can set user.name and user.email to your name and push a commit that looks like yours. A signature proves the commit was created by someone holding a specific private key and that it hasn't been altered since.
Git supports GPG, SSH keys (in recent versions, the easiest option since most developers already have one) and X.509 certificates. You set gpg.format, point user.signingKey at the key, and sign with git commit -S or always via commit.gpgSign; git tag -s signs tags. The signature is stored inside the commit object, so it travels with it.
To verify, git log --show-signature or git verify-commit; for SSH, Git needs an allowed-signers file mapping emails to keys. Hosts like GitHub and GitLab show a Verified badge when the key is registered to the account, and branch rules can require signed commits.
Signing proves authorship, not that the code is good. Rewriting history, such as a rebase, re-creates commits, so they must be signed again.
git config gpg.format ssh
git config user.signingKey ~/.ssh/id_ed25519.pub
git config commit.gpgSign true # sign every commit
git commit -m "Harden auth" # or: git commit -S
git log --show-signature -1
git verify-commit HEAD # SSH needs gpg.ssh.allowedSignersFilegpg.formatcommit.gpgSign, git commit -S, git tag -s-X theirs versus -s ours.hardGit finds the merge base, the best common ancestor of the two tips (git merge-base), and does a three-way merge: it compares base with ours and base with theirs. For each region:
That's why merges are smarter than comparing two files directly: the base shows who changed what. The default strategy, ort, also detects renames. When there are several merge bases (criss-cross history), it first merges them into a virtual base. The older recursive strategy is now just an alias for ort.
Options are easy to confuse:
-X ours / -X theirs is a strategy option: a normal merge, but conflicting hunks are resolved automatically in favour of one side. Non-conflicting changes from both sides still come in.-s ours is a whole strategy: it records a merge commit but ignores the other branch's content entirely, which is useful for marking an old branch as merged.ort is the default; detects renames-X theirs resolves conflicts; -s ours discards their changesgit rebase --onto do? Give a scenario where you need it.hardA normal git rebase main replays every commit on your branch that isn't in main. git rebase --onto <newbase> <upstream> <branch> is more precise: take the commits in upstream..branch and replay only those onto newbase.
The classic scenario: you branched featB off featA because you needed its code. Then featA was squash-merged into main, so its original commits never appear in main. A plain git rebase main from featB would try to replay featA's old commits too, causing duplicate changes and conflicts. git rebase --onto main featA featB replays only featB's own commits onto main.
It's also a way to drop a range: git rebase --onto HEAD~3 HEAD~1 removes the two commits in between.
For stacks of dependent branches, recent Git offers --update-refs, which moves every branch in the stack to its rewritten commit in one rebase. As with any rebase, it rewrites SHAs, so only do it on unshared branches.
# featB was branched from featA; featA got squash-merged into main
git rebase --onto main featA featB # replay only commits in featA..featB onto main
# stacked branches: move part1 and part2 together
git switch part2
git rebase --update-refs main # part1 is updated in the same runupstream..branch onto a new base--update-refs moves stacked branches together@v4 can be moved to malicious code, and there have been real supply-chain incidents where that happened. Pin third-party actions to a full commit SHA and let Dependabot or Renovate update them.${{ }} expressions are substituted into the script before it runs, so an attacker-controlled PR title or branch name can inject shell commands. Pass untrusted values through env and quote them.pull_request_target runs in the context of the base repository, with access to its secrets and potentially a write-capable token. Never check out and execute the PR's code in it.permissions to read-only by default and grant more per job.- uses: actions/checkout@<full-40-char-commit-sha> # pin, don't trust tags
- name: Check PR title
env:
TITLE: ${{ github.event.pull_request.title }} # data, not code
run: echo "$TITLE" | grep -qE '^(feat|fix)'
# unsafe: run: echo "${{ github.event.pull_request.title }}"pull_request_targetpermissions by defaultFirst measure: find which jobs and steps take the time, and whether it's queueing, setup or the tests themselves. Then, typically:
concurrency and cancel-in-progress..dockerignore.A common target is PR feedback in about ten minutes, because slow pipelines push people towards bigger, riskier batches.
on:
pull_request:
paths: ['services/api/**', 'libs/**'] # skip unrelated changes
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true # drop superseded runsgit worktree, and when is it better than stashing or cloning the repository twice?hardgit worktree attaches additional working directories to the same repository. git worktree add ../app-hotfix -b hotfix origin/main creates a new folder with its own checked-out branch, its own HEAD and its own index, while sharing the object database, refs and configuration with the main working tree.
It shines when you need two branches at once:
Compared with a second clone, it's cheaper: no duplicate objects, one fetch updates both, and branches and stashes are visible everywhere. The main constraint is that the same branch can't be checked out in two worktrees at the same time; Git refuses with "already used by worktree". Clean up with git worktree remove, and git worktree prune removes records of folders you deleted manually.
git worktree list, remove, pruneConceptually each commit is a snapshot, but storage is much smarter:
.git/objects.git gc, which Git also runs automatically from time to time, bundles objects into packfiles with delta compression, and fetches and pushes transfer packfiles too. Similar objects are stored as a base plus small deltas, typically keeping the newest version whole and older versions as deltas, since recent versions are read most.So the "snapshots, not diffs" model is about Git's data model and speed of checkout; physically it's heavily compressed. You can inspect this with git count-objects -v and git verify-pack -v.
The exception is binaries: they rarely delta well, which is why large binary files bloat repositories and belong in Git LFS.
No questions match that filter.
Prefer multiple choice? All 23 Git & CI/CD MCQs with answers →