Full-Stack Questions
Git, Team Workflow and Monorepos
Merge vs rebase, undoing mistakes, reflog, bisect, branching models, code review, pnpm workspaces, Turborepo, changesets and versioning.
On this page
- Words used in this lesson
- Merge, rebase and squash
- Undo: reset, revert and restore
- Recover with the reflog
- Cherry-pick, bisect and stash
- Resolve conflicts
- Pushing safely
- Branching models and branch protection
- Commits and pull requests
- Monorepos
- Versioning
- Lockfiles and dependency updates
- Common mistakes
- Interview questions
- Key takeaways
- What's next
In one line: Git questions test whether you can change history safely and fix mistakes calmly. Team questions test whether you keep a shared codebase healthy: small pull requests, clear commits, protected branches, and a monorepo that builds only what changed.
At 4+ years nobody asks you to list Git commands. They ask "you force pushed over a teammate's work, what now?" or "why did the squash-merged branch refuse to delete?". They also ask how a team of ten ships one codebase without stepping on each other. Every command and output in this lesson was run in a scratch repository with Git 2.43. The monorepo parts were run with pnpm 10, Turborepo 2.11, Nx 23, Changesets 3, node-semver 7.8 and commitlint 21.
Words used in this lesson
- A commit is a saved snapshot of your project, with a message, an author and a pointer to its parent commit. A SHA (or hash) is the long ID of a commit, such as
7d40177. Change a commit in any way and its SHA changes. - A branch is a movable label that points to one commit.
HEADis a label for "where I am now". Think of a branch as a sticky note on a commit. - The working tree is the files on your disk. The index (or staging area) is the list of changes you chose for the next commit with
git add. - A remote is another copy of the repository, usually on GitHub. A pull request (PR) is a request to merge a branch, with a place for review.
- A merge joins two branches and keeps both histories. A rebase replays your commits on top of another branch, which gives them new SHAs. A squash turns many commits into one.
- The reflog is a private diary of where
HEADand each branch pointed in the past. It is your undo log. - CI (continuous integration) is a server that runs your build and tests on every push.
- A monorepo is one repository that holds many projects (apps and packages). A workspace is a package manager feature that links those projects together.
- A lockfile is a file that records the exact version of every installed dependency, so every machine installs the same tree.
- Semantic versioning (semver) is a rule for version numbers
MAJOR.MINOR.PATCH. MAJOR means a breaking change, MINOR means a new feature that does not break, PATCH means a bug fix.
Merge, rebase and squash
Start with a small history. main has two commits. feature branched off and added two commits. Meanwhile main got one more commit.
* 7d40177 feat: add f2 (feature)* ce8e096 feat: add f1| * 3428710 main: m1 (main)|/* 023ee44 base 2* a40e04b base 1I tried all three ways to bring feature and main together.
| Way | Command | Result | History after |
|---|---|---|---|
| Merge | git merge main (on feature) | One new merge commit with two parents. Old SHAs stay. | Branchy graph, true order of events |
| Rebase | git rebase main (on feature) | 7d40177 and ce8e096 became 6945235 and d5e5bf8. | Straight line, but the commits are copies |
| Squash | git merge --squash feature then git commit | One new commit on main. The branch is not linked. | Straight line, one commit per PR |
Difference in one line: merge keeps history as it happened, rebase rewrites it to look straight, squash throws away the small steps.
The golden rule of rebase
Rebase creates new commits. If other people already have the old ones, you now have two versions of the same work. So: rebase your own, unshared branch freely. Do not rebase a branch that others build on, and never rebase main.
Squash has side effects
After a squash, Git does not know that feature was merged, because the new commit has a different SHA:
$ git branch --merged main* main$ git branch -d featureerror: the branch 'feature' is not fully merged.$ git branch -D featureDeleted branch feature (was 7d40177).The "not fully merged" error is not a bug. It is Git being honest. Platforms such as GitHub delete the branch for you after a squash merge, which hides this.
Stacked branches after a squash
A stacked branch is a branch built on top of another feature branch (ui on top of api). When api is squash-merged, ui still contains the old api commits. I tested a plain git rebase main on ui: with a two-commit parent it stopped with CONFLICT (add/add). The fix is to say "move only my commits, from the old parent to main":
git rebase --onto main api # replay the commits after "api" onto mainIf you keep a stack of branches, git rebase --update-refs main on the top branch also moves the branches below it. I checked that the middle branch moved with the tip.
Which one does a team choose?
There is no right answer, only a trade-off. Squash merge gives a clean main with one commit per PR, and is easy to revert. The cost is that detail inside the PR is lost and stacked branches need --onto. Merge commits keep everything but make main noisy. Rebase-and-merge keeps each commit but rewrites SHAs. Pick one per repository and enforce it in the settings.
For your own git pull, git pull --ff-only is a safe habit. When the branches have diverged it refuses (fatal: Not possible to fast-forward, aborting.) and does not create a surprise merge commit. You then choose merge or rebase on purpose.
Undo: reset, revert and restore
Three commands, three different jobs. This is the most common mix-up in interviews.
| Command | What it changes | Safe on shared branches? |
|---|---|---|
git reset | Moves the branch back to an older commit. The history is rewritten. | No |
git revert <sha> | Adds a new commit that undoes an old one. History is kept. | Yes |
git restore <file> | Changes files (working tree and/or index). The branch does not move. | Yes (local only) |
The three modes of reset
git reset moves the branch. The mode decides what happens to your changes:
| Mode | Branch moves | Index (staged) | Working files |
|---|---|---|---|
--soft | yes | kept, still staged | kept |
--mixed (default) | yes | cleared | kept, shown as modified |
--hard | yes | cleared | thrown away |
I ran git reset --soft HEAD~1: git status showed M a.txt (staged). With --mixed it showed M a.txt (not staged). With --hard my edit to a tracked file was gone, but an untracked file u.txt stayed. --hard only touches tracked files.
Restore, and the trap in its default
git restore a.txt copies the file from the index, not from the last commit. So after git add a.txt, a plain restore brings back the staged version, not HEAD. To go back to the last commit for both places, say it clearly:
git restore --source=HEAD --staged --worktree a.txtgit restore --staged a.txt # only unstage; keep my editgit restore --source=HEAD~2 a.txt # take the file from an older commitrestore and switch (to change branch) were added in Git 2.23 to split the many jobs of the old git checkout. Old tutorials still use checkout; it works, but it is easy to misuse.
Revert, including a merge commit
git revert HEAD creates a commit named Revert "...". A merge commit has two parents, so Git cannot know which side to undo:
$ git revert HEADerror: commit cf2c27b is a merge but no -m option was given.$ git revert -m 1 HEAD # keep parent 1 (main), undo what the branch broughtThe re-merge trap. After you revert a merge, Git thinks those changes were deliberately removed. I merged the branch again after adding one new commit: only the new file came back. The old files stayed gone, with no error. To bring the feature back, first revert the revert. Git 2.43 even names that commit Reapply "Merge feat". Then merge the branch.
Recover with the reflog
The reflog records every move of HEAD. A commit that no branch points to is not deleted at once, so you can usually get it back.
git reset --hard HEAD~1 # oopsgit reflog # find where HEAD was beforegit reset --hard HEAD@{1} # go back one step in the refloggit reset --hard ORIG_HEAD # also works right after a reset, merge or rebaseFor a deleted branch, Git prints the old tip when you delete it (Deleted branch feat (was 87551a2)). If you missed that line, git fsck --lost-found lists unreachable commits (dangling commit 87551a2...), and you can run git branch rescued 87551a2.
What can and cannot be recovered:
- Committed work: recoverable from the reflog for a long time. By the Git docs, reflog entries expire after 90 days (
gc.reflogExpire), and entries for commits no longer reachable after 30 days (gc.reflogExpireUnreachable). - Staged but never committed: I did
git add p.txtand thengit reset --hard. The file vanished, butgit fsck --lost-foundfound its content as a dangling blob. - Edited but never staged: gone. Git never saw it. This is why I commit early (a work-in-progress commit is cheap and can be amended later).
The reflog is local. It lives only in your clone. A teammate's deleted work is not in your reflog.
Cherry-pick, bisect and stash
Cherry-pick
git cherry-pick <sha> copies one commit onto your current branch. It creates a new commit (new SHA, same change). Typical use: a hotfix landed on main and must go to the release branch too.
git switch releasegit cherry-pick -x b34897f # -x adds: (cherry picked from commit b34897f...)git show on both commits gave the same patch ID, which is Git's fingerprint of a diff. That is why Git later merges the branches without a conflict. Use -x so the history says where the copy came from. If the copy conflicts, git cherry-pick --abort puts everything back. To copy a merge commit you need -m 1, as with revert. Do not use cherry-pick as your normal way to share work: duplicated commits make later merges confusing.
Bisect
git bisect finds the commit that broke something using binary search: it checks out the middle commit, you say good or bad, and it halves the range. 12 commits need about 3 or 4 tests, and 1,000 commits need about 10.
git bisect start HEAD good-tag # HEAD is bad, good-tag is goodgit bisect run node test.js # let a script decidegit bisect reset # go back to where I wasIn my test the script returned 0 on a good commit and 1 on a bad one, and Git printed c9 refactor add as the first bad commit after 3 steps. The exit code rules from the Git docs: 0 means good, 1 to 127 (except 125) means bad, 125 means "cannot test this commit, skip it" (for example when the build is broken), and anything above 127 stops the whole bisect. The test must be repeatable. A flaky test gives a wrong answer.
Stash
git stash saves your uncommitted changes on a shelf and gives you a clean tree. The details are where people get caught:
| Command | Saves |
|---|---|
git stash | Changes to tracked files, staged and unstaged. Not new (untracked) files. |
git stash -u | Also untracked files. Ignored files (like node_modules) stay. |
git stash -a | Also ignored files. Rarely a good idea. |
git stash pop | Apply the newest stash and delete it. |
git stash apply | Apply it and keep it. |
git stash branch fix | Make a new branch from the commit the stash was taken on, apply, then drop. |
With plain git stash, my untracked.txt stayed in the folder. With -u it moved to the shelf and node_modules stayed. When git stash pop hits a conflict, Git prints The stash entry is kept in case you need it again. So nothing is lost. A stash is also easy to forget: name it with git stash -m "wip: login form" and check git stash list.
Resolve conflicts
A conflict happens when two branches changed the same lines in different ways. Git cannot choose, so it writes both versions into the file:
<<<<<<< HEADtimeout=30=======timeout=20>>>>>>> mainBetween <<<<<<< and ======= is "ours", between ======= and >>>>>>> is "theirs". You edit the file to the final text, remove the markers, run git add <file>, and then git commit (merge) or git rebase --continue. git merge --abort or git rebase --abort returns to the state before you started.
Ours and theirs flip in a rebase. In a merge, "ours" is your branch. In a rebase, Git builds the result on top of the target branch, so "ours" is the branch you rebase onto and "theirs" is your own commit being replayed. I checked it: during git rebase main, git checkout --ours cfg.txt gave timeout=20 (from main), and --theirs gave timeout=30 (my commit). Read the marker label before you pick a side.
Two settings help a lot:
git config merge.conflictStyle zdiff3also shows the base version between|||||||and=======. You see what both sides started from, which makes the right answer much clearer. (diff3is the older style with the same idea.)git config rerere.enabled trueturns on rerere ("reuse recorded resolution"). Git remembers how you resolved a conflict. When the same conflict appears again, for example in a long rebase you repeat, it printsResolved 'cfg.txt' using previous resolution.You still have togit addthe file.
Pushing safely
After a rebase or --amend, your local branch and the remote branch have different histories, so a normal push is rejected (! [rejected] feat -> feat (fetch first)). You must force. There are three choices:
| Command | What it does |
|---|---|
git push --force | Overwrites the remote branch, whatever is there. |
git push --force-with-lease | Overwrites only if the remote branch is where my remote-tracking branch says it is. |
git push --force-with-lease --force-if-includes | The same, and also refuses if I have not included the remote's newest commits locally. |
I tested a teammate pushing a commit to feat while I amended mine:
- Plain push: rejected, "fetch first".
--force-with-lease: rejected, "stale info". Good, it protected the teammate.- Now the trap. I ran
git fetch(an editor can do this in the background), then--force-with-leaseagain. It succeeded and the teammate's commit disappeared from the remote. Fetching moved my remote-tracking ref, so the "lease" matched. - With
--force-if-includesthe push was rejected:remote ref updated since checkout.
So the full habit is: git push --force-with-lease --force-if-includes. And a force push only on your own feature branch.
Branching models and branch protection
A branching model is the team's agreement about which branches exist and how work moves between them.
| Trunk-based development | Gitflow | |
|---|---|---|
| Long-lived branches | Only main (the trunk) | main, develop, plus release/* and hotfix/* |
| Feature branches | Short: hours to 1-2 days, or none | Can live for weeks |
| Releasing | Deploy from main often | Cut a release branch, stabilize, merge to main and develop |
| Unfinished work | Hidden behind a feature flag | Kept on a branch |
| Needs | Good tests, fast CI, feature flags | A slow, scheduled release |
| Pain | Needs discipline and confidence in tests | Painful merges, drift between branches |
A feature flag is a setting that turns code on or off at runtime without a new deploy. It lets you merge unfinished code safely because users never see it.
For a web app that deploys many times a day, trunk-based is the common choice for modern teams. Gitflow suits software with versions that must be supported in parallel, such as a mobile app or a library with an old major version. The honest answer in an interview is "it depends on how you release". I would also say that the main weakness of long branches is that the merge cost grows with the time apart.
Branch protection
Branch protection means server-side rules on main that nobody can skip by accident. On GitHub these are called branch protection rules or rulesets. A common set:
- Require a pull request and at least one approval. Dismiss stale approvals when new commits arrive.
- Require status checks (build, tests, lint, types) to pass, and the branch to be up to date.
- Require a code-owner review for sensitive paths (a
CODEOWNERSfile maps paths to people or teams). - Block force pushes and deletion of
main. Optionally require a linear history or signed commits. - Use a merge queue if many PRs land each day, so each one is tested against the real latest
main.
Why on the server? A local hook can be skipped. I made a pre-push hook that failed, and git push --no-verify ignored it. In a bare repository I set receive.denyNonFastForwards, and the force push was rejected with denying non-fast-forward refs/heads/main. Local hooks are for speed (fast feedback). The server rule is the real wall.
Commits and pull requests
Conventional Commits
Conventional Commits is a small convention for commit messages, so tools and people can read them:
<type>[optional scope][!]: <description>[optional body][optional footer(s)]From the specification: fix means a bug fix (semver PATCH), feat means a new feature (semver MINOR), and a ! after the type or a BREAKING CHANGE: footer means a breaking change (semver MAJOR). Other types (docs, chore, refactor, test, ci, perf) are allowed and have no version effect by themselves.
I ran commitlint (a tool that checks messages) with @commitlint/config-conventional. It accepted feat(auth): add login, fix: null check, feat!: drop node 18 and chore(deps): bump zod. It rejected Fixed stuff (type may not be empty) and feat: Add Login. (subject must not be sentence-case, subject may not end with full stop). Run it in CI and in a commit-msg hook.
Is the convention worth it? Yes if it drives something: automated changelogs, version bumps, or a quick git log --grep. If the team squash-merges, only the PR title ends up in main, so lint the title, not each small commit.
Good commits and tidy history
- One logical change per commit. The message explains why, not only what.
- Use
git commit --fixup <sha>for a small correction to an earlier commit, thengit rebase -i --autosquash mainfolds it in. I ran this withGIT_SEQUENCE_EDITOR=:and thefixup!commit disappeared into its target. - Use
git log -S"text"to find the commit that added or removed a string, andgit blameto see who changed a line and why. - Use
git diff main...feature(three dots) to see what the branch changed since it split off. Two dots compares the two tips directly, so changes that arrived onmainshow up as removals. In my test: 2 files with three dots, 3 files with two dots.
PR size and review habits
Small pull requests get better reviews. A reviewer can really read 100 to 300 changed lines; a 2,000-line PR gets "looks good to me". I treat these numbers as a rule of thumb, not a law. How to keep PRs small:
- Split refactors from behavior changes. Move files in one PR, change logic in the next.
- Merge unfinished work behind a feature flag instead of keeping a giant branch.
- Add the database migration or the new API first, the UI after (see Backend, Lesson 08 for safe migrations).
- Stack small PRs (
--ontoand--update-refsmake this easy).
As an author I write a description with what, why, how I tested it, and screenshots for UI changes. I review my own diff first. As a reviewer I check, in order: does it solve the right problem, is it correct and safe (errors, security, edge cases), is it tested, is it easy to read. I leave style to the linter. I mark comments as "must fix" or "nit", ask questions rather than give orders, and answer within a working day, because a waiting PR blocks a person.
Monorepos
A monorepo is one repository with many packages, for example:
apps/web Next.js siteapps/api Express APIpackages/schemas Zod schemas used by bothpackages/ui shared React componentsWhy: one pull request can change a shared package and every app that uses it, with one set of versions and one CI. There is no "publish, bump, install" delay between a library and its users. Cost: CI gets slow unless the tools build only what changed, permissions are shared, and one broken package can block everyone. A polyrepo (many repositories) has the opposite trade-offs: independent versions and access, but hard cross-project changes. Lesson 13 has a short summary; here is the detail.
pnpm workspaces
pnpm is a package manager (like npm) that stores each package version once on disk and links it into projects. A workspace is declared in pnpm-workspace.yaml:
packages: - apps/* - packages/*A package uses another one with the workspace: protocol:
"dependencies": { "@acme/schemas": "workspace:*" }With workspace:*, pnpm refuses to take anything but the local package. In my lab, node_modules/@acme/schemas was a symlink to packages/schemas. When you pack or publish, pnpm rewrites workspace:* to the real version: pnpm pack produced "@acme/ui": "1.0.0". pnpm is also strict: a package can only import what it lists in its own package.json. I loaded semver from a package that did not list it and got Cannot find module 'semver', while the package that listed it could load it. That error is good: it catches "phantom dependencies", which work on your machine because something else installed them, and then break in production.
Useful selectors, the same in pnpm and Turborepo:
| Filter | Selects |
|---|---|
web... | web and everything it depends on |
...@acme/ui | @acme/ui and everything that depends on it (its dependents) |
...[HEAD~1] (Turborepo) | packages changed since HEAD~1, plus their dependents |
I ran these on a three-package graph (schemas <- ui <- web). web... gave schemas, ui, web. ...@acme/ui gave ui, web.
Turborepo (task runner and cache)
Turborepo is a task runner for JavaScript monorepos. It knows the package graph, runs independent tasks in parallel and caches the output. It is configured in turbo.json:
{ "tasks": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] }, "test": { "dependsOn": ["build"] } }}- The top-level key is
tasks(older versions called itpipeline). "^build"means "thebuildof my dependencies first"."build"without^means the build of the same package.outputsis what to save in the cache. Without it, Turborepo caches the log but does not restore any files.
My run: the first turbo run build was 3 cache misses (1.3 seconds). The second was 3 cached, 3 total, 28ms >>> FULL TURBO. When I changed only packages/ui, schemas was a hit, and ui and web were rebuilt. When I changed only apps/web, only web was rebuilt. The cache key is a hash of the task's inputs: the package files, the files of its dependencies, the task config and the environment variables you list.
Environment variables in strict mode. Turborepo only passes to a task the variables you list in env (or globalEnv). I set API_URL and the task printed undefined. After I listed it in the task's env, it printed the value, and changing the value changed the cache key (values a, a, b gave miss, hit, miss). Forget to list it and you get either a missing variable, or a stale cached build made with another value.
To run only what a PR touched, use turbo run build --affected. It is the same as --filter=...[main...HEAD] by the docs. It needs the Git history: a shallow clone (fetch-depth: 1) makes Turborepo treat everything as changed. A remote cache (turbo login, then link) shares the cache between CI and all developers, so one person's build serves the others. Cache outputs can contain secrets or be poisoned, so give write access only to trusted CI.
Nx
Nx is another monorepo tool. The ideas match Turborepo: a project graph, cached tasks, and "affected" commands. In my run, targetDefaults in nx.json with dependsOn: ["^build"] worked the same, the second run was Cache: 3/3 hit (100%), and nx show projects --affected --base=main printed @acme/ui and web after I changed only ui. Nx also has code generators and many plugins, so it does more, and costs more to learn. A short way to answer "Turborepo or Nx?": Turborepo is a small, fast task runner you add to an existing pnpm repo; Nx is a larger toolkit with generators, plugins and a stronger project model. Both solve the same main problem.
Shared packages: compiled or "just in time"
How does apps/web use packages/ui? The Turborepo docs name three ways:
| Way | How | Trade-off |
|---|---|---|
| Just-in-time | The package exports its TypeScript source; the app's bundler compiles it. | Least setup. The package has no build step, so Turborepo cannot cache it. The consumer must transpile (Next.js needs transpilePackages). |
| Compiled | The package builds to dist/ with .d.ts files. | More config, but cacheable, and works for any consumer. |
| Publishable | Built and published to npm. | Needed for outside users. Adds versioning work. |
For two apps in the same repo I start just-in-time, and move to compiled when builds get slow or a non-bundler (a Node script) must import it.
Versioning
Semantic versioning and ranges
In MAJOR.MINOR.PATCH, a breaking change raises MAJOR, a backward-compatible feature raises MINOR, and a compatible fix raises PATCH. Version 0.y.z means "initial development; anything may change". A range in package.json says which versions are acceptable:
| Range | Means | I tested |
|---|---|---|
^1.2.3 | >=1.2.3 <2.0.0 | 1.9.0 yes, 2.0.0 no |
~1.2.3 | >=1.2.3 <1.3.0 | 1.2.9 yes, 1.3.0 no |
^0.2.3 | >=0.2.3 <0.3.0 | 0.2.9 yes, 0.3.0 no |
^0.0.3 | exactly 0.0.3 | 0.0.4 no |
The caret ^ means "do not change the left-most non-zero number". That is why ^0.x is much tighter than ^1.x. A prerelease (5.1.0-next.0) does not match ^5.0.0, and not even >=5.0.0-0: a prerelease only matches a range that names a prerelease on the same major.minor.patch. And versions are numbers, not text: '1.10.0' > '1.9.0' is false in JavaScript as strings; use the semver package.
Changesets
Changesets is a tool that records version intent per pull request. The author runs changeset and writes a small file in .changeset/:
---"@acme/schemas": minor---Add the `Invoice` schema.When it is time to release, changeset version reads all these files, bumps versions, writes CHANGELOG.md, deletes the files, and also bumps packages that depend on the changed one. In my lab, schemas went to 1.1.0, and ui and web to 1.0.1 with "Updated dependencies" lines. Then changeset publish sends the packages to npm. In CI, changeset status --since=main exits with code 1 when packages changed but no changeset exists; changeset --empty adds a changeset that releases nothing (for test-only changes). The common automation is a bot plus a GitHub Action that keeps a "Version Packages" PR open.
Compare with Conventional Commits: commit-driven tools decide the bump from commit messages. Changesets asks a human, per PR, which is clearer in a monorepo where one commit can touch many packages. It costs one extra file per PR. Prereleases need care: the docs warn that they are complex, and say to avoid prereleasing from the default branch. If your apps are never published to npm, you may not need versioning at all, just the deploy SHA.
Lockfiles and dependency updates
A lockfile (package-lock.json, pnpm-lock.yaml) records the exact resolved version of every package, so ^2.0.0 does not drift between machines. Always commit it.
- In CI use
npm ciorpnpm install --frozen-lockfile. They install exactly the lockfile and fail whenpackage.jsonand the lockfile disagree. I gotnpm ci can only install packages when your package.json and package-lock.json are in sync, andERR_PNPM_OUTDATED_LOCKFILE. pnpm uses frozen mode by default when it detects CI. npm installmay change the lockfile; use it on your machine when you add a dependency.- Lockfile merge conflicts: do not hand-edit them. Fix the
package.jsonconflict first, then runpnpm install. In my test it printedMerge conflict detected in pnpm-lock.yaml and successfully mergedand removed the markers. For npm, the usual fix is to take one side and runnpm installagain, then review the diff.
Keeping dependencies fresh
Old dependencies are a security risk; blind updates are a stability risk. Renovate and Dependabot are bots that open pull requests for new versions. The settings that matter:
- Group related updates (all
@types/*, alleslint*) into one PR to reduce noise. Renovate hasgroupName; Dependabot hasgroups. - Wait before updating. Renovate's
minimumReleaseAgedelays a new version by some days; its docs note npm packages can be unpublished within 72 hours. Dependabot hascooldown. - Auto-merge only patch and dev-tool updates that pass CI.
- Renovate's
lockFileMaintenance(off by default) deletes the lockfile and rebuilds it to refresh indirect dependencies.
The test suite is what makes any of this safe. A bot with no tests is a risk generator.
Common mistakes
- Rebasing a branch that others already pulled, then force pushing with plain
--force. - Using
git reset --hardon shared history instead ofgit revert. - Reverting a merge, then merging the same branch again and wondering why files are missing.
- Stashing without
-uand then losing track of new files, or forgetting old stashes. - Opening a 3,000-line PR that mixes a refactor and a feature.
- Relying on a local hook for a rule that the server must enforce.
- Not listing a task's
envinturbo.json, or not settingoutputs. - Importing an undeclared dependency and relying on hoisting.
- Using
npm installin CI, so the build is not reproducible. - Merging bot PRs without reading what changed.
Interview questions
What is the difference between merge, rebase and squash? (Must know)
Merge joins two branches with a merge commit and keeps the real history and the old SHAs. Rebase replays my commits on top of the target, so I get a straight line, but the commits are new copies with new SHAs. Squash combines all commits of a branch into one commit on the target. I use rebase on my own unshared branch to stay current with main, and squash merge for the final PR if the team wants one commit per PR. The rule I never break: I do not rebase commits that others already pulled. A side effect of squash: Git does not see the branch as merged, so git branch -d refuses and I need -D.
When do you use reset, revert and restore? (Must know)
reset moves the branch pointer, so it rewrites history. I use it only on local commits that nobody has. --soft keeps my changes staged, --mixed keeps them unstaged, --hard discards them. revert makes a new commit that undoes an old one, so it is the safe choice for anything already pushed to a shared branch. restore changes files, not the branch: git restore --staged file unstages, and git restore --source=HEAD --staged --worktree file returns the file to the last commit. One gotcha: plain git restore file restores from the index, not from HEAD.
I ran git reset --hard and lost commits. How do I get them back? (Must know)
I run git reflog, find the line where HEAD was before the reset, and run git reset --hard HEAD@{1} or the SHA. Right after a reset, ORIG_HEAD also points to the old tip. For a deleted branch, the delete message printed the old SHA, and if I missed it, git fsck --lost-found lists dangling commits, and git branch rescued <sha> brings it back. By the docs the reflog keeps entries 90 days, and 30 days for unreachable ones, and it is local to my clone. Limits: edits I never staged are gone for good. A file I staged but never committed can still be found as a dangling blob with fsck.
How do you undo a merge that is already on main? What is the catch? (Senior signal)
I use git revert -m 1 <merge-sha>. A merge has two parents, so -m 1 says "keep the first parent, which is main, and undo what the branch brought". Without -m Git refuses. The catch: Git now treats those changes as deliberately removed. If the branch is fixed and merged again, only the new commits come back and the old files stay missing. In my test one new file arrived and the original ones did not, with no warning. The right order is to revert the revert first, which restores the code, and then merge the fixed branch. Or I make a new branch with the fix and open a fresh PR.
How would you push after a rebase without hurting a teammate? (Senior signal)
A plain push is rejected, so I need a force, and I use git push --force-with-lease. It only overwrites if the remote branch is where my last fetch saw it. That protects me from overwriting a commit I never saw. But there is a hole: if my editor already ran git fetch, my remote-tracking branch is updated, the lease matches, and the push wipes out the teammate's commit. I tested this and the commit really was gone from the remote. Adding --force-if-includes closes the hole: it refuses if the remote has commits my local branch does not include. I also only force push my own feature branch, never main, and branch protection blocks it there.
How do you find which commit introduced a bug? (Must know)
I use git bisect. I mark the current commit as bad and an older one as good. Git checks out the middle commit, I test, and it halves the range, so 1,000 commits need about 10 steps. Better, I automate it: git bisect run npm test, or a small script. The exit code rules matter: 0 is good, 1 to 127 (not 125) is bad, 125 means "skip this commit" (the build is broken), and above 127 aborts. The test must be deterministic, or the answer is wrong. At the end I run git bisect reset. If I cannot reproduce with a script, I can still do it by hand. If history has many merge commits, I also use git log -S"text" to search for when a string appeared.
What does git stash not save by default, and how do you avoid losing work? (Trap/gotcha)
By default it saves tracked files only, staged and unstaged. New untracked files stay in the folder. -u includes untracked files, -a also includes ignored ones such as node_modules. I tested all three. git stash pop applies the newest entry and deletes it, but if it conflicts, Git keeps the entry ("The stash entry is kept in case you need it again"), so nothing is lost. git stash apply keeps it always. For longer pauses I prefer a work-in-progress commit on a branch, since stashes are easy to forget. git stash branch <name> is good when the base has moved so far that applying the stash conflicts.
During a rebase I run git checkout --ours file. Which version do I get? (Trap/gotcha)
The version from the branch I am rebasing onto, not mine. In a rebase Git replays my commits one by one on top of the target, so "ours" is the target-branch side and "theirs" is the commit being replayed. It is the opposite of a merge. I confirmed it: with main at timeout=20 and my commit at timeout=30, --ours gave 20. To avoid mistakes I read the marker labels, which show the commit message of my commit on the "theirs" side, and I turn on merge.conflictStyle zdiff3 to see the base version too. For repeated conflicts in a long rebase, rerere replays my earlier resolution.
When do you use cherry-pick, and what are the risks? (Senior signal)
Mostly for backports: a fix is on main and must also go into a release branch. git cherry-pick -x <sha> copies the change as a new commit and adds "(cherry picked from commit ...)" to the message so the origin is recorded. The patch is the same, so later merges between the branches usually work without conflict. In my test they did. Risks: duplicate commits make history harder to read; the copy can conflict if the target branch differs, and then I use --abort or fix it; and a merge commit needs -m 1. If I find myself cherry-picking all the time, the branching model is wrong. Fix the model.
Trunk-based development or Gitflow? How do you decide? (Senior signal)
I decide by how the product is released. For a web app deployed many times per day I choose trunk-based: one main branch, short-lived feature branches of a day or two, unfinished work behind feature flags, and fast CI that protects main. It avoids long branches, whose merge cost grows with time. Gitflow fits when several versions are supported at once, like a mobile app or a library with old majors, because release/* and hotfix/* branches match that life cycle. Gitflow costs more merges and drift between develop and main. Trunk-based needs trust in tests; without tests it is risky. Either way I protect main on the server.
What branch protection would you set on main, and why not just use Git hooks? (Must know)
Require a PR with at least one approval, require CI checks (build, tests, lint, types), require the branch to be up to date or use a merge queue, require code-owner review for sensitive folders, and block force pushes and deletion of main. Optionally require a linear history. Git hooks live on each developer's machine and can be skipped; I made a pre-push hook fail and --no-verify bypassed it. A server rule cannot be skipped. I used receive.denyNonFastForwards on a bare repo and the force push was rejected. So hooks are for fast feedback and the server is the real gate. Rules should also apply to admins, or they will be skipped "just this once".
Are Conventional Commits worth it? (Must know)
They are worth it when something uses them. The format is type(scope)!: description. feat means MINOR, fix means PATCH, and ! or a BREAKING CHANGE: footer means MAJOR. Tools can then build changelogs and suggest versions, and git log is easy to scan. I enforce it with commitlint; I ran it and it rejected Fixed stuff and feat: Add Login. If the team squash-merges, only the PR title survives, so I lint the PR title. If nothing reads the messages, I would drop the strict rules and keep a simple "why, not what" habit. The honest cost is friction for new contributors.
How big should a PR be, and how do you keep it small? (Senior signal)
I aim for something a reviewer can read in 15 to 20 minutes, roughly 100 to 300 lines, as a rule of thumb. Beyond that, reviews get shallow. To stay small: split refactors from behavior changes; ship the migration or API first and the UI later; hide unfinished work behind a flag; and stack small PRs, using git rebase --onto or --update-refs when a parent is squash-merged. Generated files and moves can be large if I say so in the description. A good PR description gives what, why, how I tested, and screenshots. As a reviewer I check correctness and risk first and let the linter handle style. I label "nit" comments so they do not block.
Why use a monorepo, and what does it cost? (Must know)
One repository for web, API and shared packages means a change to a shared schema and all its users goes into one PR, with one version of each dependency and one CI. There is no "publish, bump, wait" step. The costs are real: CI slows down unless tools build only what changed, one broken package can block everyone, access control is coarse, and the tooling needs an owner. A polyrepo gives independent releases and permissions but makes cross-cutting changes painful. For one team with shared TypeScript types, I pick a monorepo. For separate teams with different release cycles and security needs, I think twice.
How does Turborepo make a monorepo fast, and what can go wrong with its cache? (Senior signal)
It reads the package graph and runs tasks in the right order and in parallel. "dependsOn": ["^build"] means build my dependencies first. It hashes each task's inputs (files, dependency files, config, listed env vars) and, on a hit, replays the logs and restores the outputs. My run went from 3 misses to FULL TURBO in 28 ms, and changing ui rebuilt ui and web but not schemas. A remote cache shares this with CI. What goes wrong: missing outputs means files are not restored; an environment variable not listed in env is invisible in strict mode (my API_URL was undefined) or gives a stale build; and a task with side effects should be cache: false. In CI I use --affected with a full-enough Git history.
How do apps use a shared package in a monorepo? (Senior signal)
With the workspace protocol: "@acme/ui": "workspace:*", so pnpm links the local folder and refuses to fetch another copy from the registry. On publish, pnpm rewrites it to the real version. For the package's code there are three choices. Just-in-time: export TypeScript source and let the app's bundler compile it; simple, but Turborepo has no build to cache and Next.js needs transpilePackages. Compiled: build to dist with types; more setup, cacheable, works for any consumer. Publishable: for outside users. I start just-in-time and move to compiled when builds slow down. pnpm's strictness also helps: a package that imports something it did not declare fails at once instead of working by accident.
How do you version and release packages in a monorepo? (Senior signal)
For published packages I use Changesets. Each PR with a user-visible change adds a small file saying which packages change and whether it is patch, minor or major, with a note for the changelog. CI checks that with changeset status --since=main; test-only PRs use changeset --empty. A bot opens a "Version Packages" PR; when I merge it, changeset version has bumped the versions, written changelogs and also bumped dependents (in my lab, minor on schemas made ui and web patch). Then changeset publish pushes to npm and tags. Apps that are deployed and not published do not need this; the Git SHA is the version. Semver only works if the team agrees what "breaking" means, so I review the bump type in the PR.
What does ^1.2.3 mean, and why is ^0.2.3 different? (Trap/gotcha)
^1.2.3 allows >=1.2.3 <2.0.0: any minor or patch update. The caret keeps the left-most non-zero number fixed. For ^0.2.3 that number is the 2, so the range is >=0.2.3 <0.3.0, and for ^0.0.3 it is exactly 0.0.3. I tested 0.2.9 yes, 0.3.0 no, and 0.0.4 no. The reason is that semver says versions below 1.0.0 are unstable, so a minor bump may break. Two more traps: a prerelease like 5.1.0-next.0 does not satisfy ^5.0.0, and string comparison fails, because '1.10.0' > '1.9.0' is false. I use the semver package. The range says what is allowed; the lockfile says what I actually get.
What is a lockfile for, and how do you handle its conflicts and bot updates? (Must know)
The lockfile pins every direct and indirect dependency, so all machines and CI install the same tree even though package.json has ranges. I commit it and in CI I use npm ci or pnpm install --frozen-lockfile, which fail if the two files disagree. I saw both errors. For a lockfile conflict I never merge by hand: I resolve package.json first and run pnpm install, which merged pnpm-lock.yaml for me in my test; with npm I take one side and run npm install again. For updates I use Renovate or Dependabot with grouping, a release-age delay (minimumReleaseAge or cooldown) so I do not take a version that is hours old, and auto-merge only for safe, tested updates. Each update still needs green CI, because the bot does not know my app.
Key takeaways
- Merge keeps history, rebase rewrites it (new SHAs), squash collapses it. Never rebase shared commits. After a squash,
branch -drefuses and stacked branches needrebase --onto. - reset moves the branch, revert adds an undo commit, restore changes files. Plain
restorereads from the index.reset --hardloses unstaged edits. - Reflog brings back committed work (about 90 days, 30 for unreachable). It is local.
- A reverted merge blocks the same changes from coming back. Revert the revert first.
--force-with-leaseis not enough after a background fetch. Add--force-if-includes.- Bisect halves the range;
runplus exit codes (0 good, 1-127 bad, 125 skip) automates it. - In a rebase, ours and theirs are flipped. Use
zdiff3andrerere. - Protect
mainon the server. Hooks can be skipped. - Prefer trunk-based for web apps with feature flags; Gitflow for versions supported in parallel.
- Small PRs (about 100-300 lines), clear descriptions, lint style by machine, answer reviews fast.
- Monorepo: pnpm workspaces link packages (
workspace:*), Turborepo/Nx cache by input hash and run only what is affected. Listenvandoutputs. - Changesets records the bump per PR; semver
^0.xis tight; the lockfile is the truth, installed withnpm cior--frozen-lockfile. - Bots (Renovate, Dependabot) need grouping, a delay and tests.
What's next
Put the habits to work in the Experience-Level Round: code review stories and ownership. For the CI side of fast pipelines, see Debugging, Lesson 14, and for the tools around the repository see Libraries and Tooling.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- The Mental Model: Snapshots, Commits and Branches · Git & GitHub
- The Daily Loop: Status, Add, Commit, Diff and Log · Git & GitHub
- Branches, Merging and Conflicts · Git & GitHub
Part of the study guide
Related series