Tech Lead NotesRohit’s interview study guide
Chapter 04

Git and Pull Request Workflows

How a team moves code from a laptop to production with Git: trunk-based development vs GitFlow, what merge, squash and rebase do to history, commit messages people can use, release branches and hotfixes, recovering from mistakes, and finding a regression with git bisect. Every Git behaviour on this page comes from a script that ran real git (2.54.0) in a throwaway repository.

6 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Trunk-based development vs GitFlow

Added

A branching strategy is a team's answer to one question: how long does a change live apart from everyone else's before it's integrated? Everything else (which branches exist, who merges what, when you tag) follows from that answer. The longer changes stay apart, the more they drift, and the more painful and risky the eventual merge.

The mental model: think of the main branch as a shared document and each branch as a private copy someone is editing. If you copy it in the morning and paste your edits back by lunch, nothing much has changed underneath you. If you keep your copy for three weeks while ten colleagues do the same, merging back means reconciling three weeks of everyone's edits at once, usually right before a deadline. Trunk-based development keeps the copies small and short-lived; GitFlow accepts long-lived copies and adds structure to manage them.

GitFlow was described by Vincent Driessen in "A successful Git branching model" (5 January 2010). It has two permanent branches, main (he called it master: production releases only) and develop (the integration branch), plus three kinds of temporary branch: feature/* from develop, release/* from develop (stabilise, bump the version, merge into both main and develop), and hotfix/* from main (merge into both). Every merge uses --no-ff so each branch stays visible in the graph. In a "Note of reflection" added on 5 March 2020, Driessen wrote that for software delivered continuously, such as web apps, teams should adopt a much simpler workflow like GitHub flow, and that git-flow still suits explicitly versioned software, or software where several versions are supported in the wild.

Trunk-based development keeps one long-lived branch (main, the trunk). Everyone integrates into it at least daily, through short-lived branches and pull requests or by committing directly. Unfinished work is hidden behind feature flags instead of being kept on a branch. DORA lists it as one of its capabilities (dora.dev, "Trunk-based development", checked October 2026), with three practices: three or fewer active branches in the repository, branches merged to trunk at least once a day, and no code freezes or integration phases. The page cites the 2016 and 2017 State of DevOps reports for the link to delivery performance. Paul Hammant's trunkbaseddevelopment.com is the long-form reference.

GitFlow: two permanent branches, three kinds of temporary one main hotfix/* release/* develop feature/* v1.1.0 v1.1.1 6 merge commits Trunk-based: one long-lived branch, others live for hours main v1.1.0 v1.1.1 PR, hours PR, hours unfinished work hides behind feature flags 0 merge commits
The same two features, one release and one hotfix. GitFlow keeps every branch visible; trunk-based development keeps every change close to main.

Here are both models running for real: two features, a release and a hotfix, shipped each way, with git log --graph showing the history each one leaves behind.

branching-models.mjs
// The same three changes and one hotfix, shipped with GitFlow and with trunk-based development.
// Real git in throwaway repositories with fixed names and dates (deterministic hashes, Git 2.54).
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

function repo() {
  const dir = mkdtempSync(join(tmpdir(), "git-demo-"));
  let minute = 0;
  const git = (...args) => {
    const date = `${1767603600 + 60 * minute++} +0000`;
    return execFileSync("git", args, {
      cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
      env: { ...process.env, LC_ALL: "C", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1",
        GIT_AUTHOR_NAME: "Asha", GIT_AUTHOR_EMAIL: "asha@example.com", GIT_AUTHOR_DATE: date,
        GIT_COMMITTER_NAME: "Asha", GIT_COMMITTER_EMAIL: "asha@example.com", GIT_COMMITTER_DATE: date },
    }).trimEnd();
  };
  let n = 0;
  const commit = (msg) => { writeFileSync(join(dir, `f${n++}.txt`), msg); git("add", "."); git("commit", "-q", "-m", msg); };
  git("-c", "init.defaultBranch=main", "init", "-q");
  commit("Initial commit");
  return { git, commit, done: () => rmSync(dir, { recursive: true, force: true }) };
}
const merge = (git, into, from, msg) => { git("switch", "-q", into); git("merge", "-q", "--no-ff", from, "-m", msg); };

const flow = repo();
try {
  const { git, commit } = flow;
  git("switch", "-q", "-c", "develop");
  for (const f of ["search", "filters"]) {
    git("switch", "-q", "-c", `feature/${f}`, "develop");
    commit(`Add ${f}`);
    merge(git, "develop", `feature/${f}`, `Merge feature/${f} into develop`);
  }
  git("switch", "-q", "-c", "release/1.1", "develop");
  commit("Bump version to 1.1.0");
  merge(git, "main", "release/1.1", "Merge release/1.1");
  git("tag", "v1.1.0");
  merge(git, "develop", "release/1.1", "Merge release/1.1 into develop");
  git("switch", "-q", "-c", "hotfix/1.1.1", "main");
  commit("Fix crash on empty search");
  merge(git, "main", "hotfix/1.1.1", "Merge hotfix/1.1.1");
  git("tag", "v1.1.1");
  merge(git, "develop", "hotfix/1.1.1", "Merge hotfix/1.1.1 into develop");
  for (const b of ["feature/search", "feature/filters", "release/1.1", "hotfix/1.1.1"]) git("branch", "-q", "-D", b);
  console.log("GitFlow: main, develop, feature, release and hotfix branches");
  console.log(git("log", "--graph", "--oneline", "--decorate", "--all"));
  console.log(`merge commits: ${git("rev-list", "--merges", "--count", "--all")}`);
} finally { flow.done(); }

const trunk = repo();
try {
  const { git, commit } = trunk;
  for (const f of ["search", "filters"]) {
    git("switch", "-q", "-c", f); // a branch that lives for hours, not weeks
    commit(`Add ${f} (behind a feature flag)`);
    git("switch", "-q", "main");
    git("merge", "-q", "--ff-only", f);
    git("branch", "-q", "-d", f);
  }
  git("tag", "v1.1.0"); // release straight from main
  commit("Fix crash on empty search");
  git("tag", "v1.1.1");
  console.log("\nTrunk-based: short-lived branches, every change lands on main");
  console.log(git("log", "--graph", "--oneline", "--decorate", "--all"));
  console.log(`merge commits: ${git("rev-list", "--merges", "--count", "--all")}`);
} finally { trunk.done(); }
Result
GitFlow: main, develop, feature, release and hotfix branches
*   26e394b (HEAD -> develop) Merge hotfix/1.1.1 into develop
|\  
* \   3ed5e7f Merge release/1.1 into develop
|\ \  
| | | *   8e7c20b (tag: v1.1.1, main) Merge hotfix/1.1.1
| | | |\  
| | | |/  
| | |/|   
| | * | 2811ba2 Fix crash on empty search
| | |/  
| | *   b80eec3 (tag: v1.1.0) Merge release/1.1
| | |\  
| | |/  
| |/|   
| * | b00eb21 Bump version to 1.1.0
|/ /  
* |   a1e177e Merge feature/filters into develop
|\ \  
| * | 0148762 Add filters
|/ /  
* |   fbb53ee Merge feature/search into develop
|\ \  
| |/  
|/|   
| * 4fdd81b Add search
|/  
* 5483f1f Initial commit
merge commits: 6

Trunk-based: short-lived branches, every change lands on main
* f2c0251 (HEAD -> main, tag: v1.1.1) Fix crash on empty search
* 84bf6c6 (tag: v1.1.0) Add filters (behind a feature flag)
* 1ca0748 Add search (behind a feature flag)
* 5483f1f Initial commit
merge commits: 0
What’s happening
  1. The model creates two throwaway repositories. Every git call gets fixed names, emails and dates (a fake clock advancing a minute per call), and GIT_CONFIG_GLOBAL=/dev/null so nothing from the machine's own Git settings leaks in. That's why the hashes are identical on every run: a commit hash is a hash of its content, its parents, its author, its committer and their dates.
  2. GitFlow: develop branches off main. Each feature gets its own feature/* branch off develop and comes back with a --no-ff merge, so the graph shows a bump for each. Then release/1.1 branches off develop, gets a version-bump commit, and is merged twice: into main (tagged v1.1.0) and back into develop, so the bump isn't lost.
  3. The hotfix repeats the double merge: hotfix/1.1.1 branches off main at the release, gets the fix, merges into main (tagged v1.1.1) and into develop. Forgetting that second merge is the classic GitFlow bug: the next release from develop silently brings the crash back.
  4. The result is the tangle at the top of the output: six merge commits and graph lines crossing each other for four real changes. Nothing is wrong with it; it's the cost of keeping every branch's identity in history. Reading it needs --first-parent or a good GUI.
  5. Trunk-based: each feature lives on a branch for a few hours, merges with --ff-only (no merge commit, because nothing else landed meanwhile) and is deleted. The release is just a tag on main; the hotfix is the next commit on main, tagged v1.1.1. The history is a straight line of four commits and zero merge commits.
  6. The difference isn't the commands; it's how long work stays apart. GitFlow integrates features into main only at release time; trunk-based integrates every change into main the same day, so main is always the latest truth and always releasable.
AdvancedGitFlow and trunk-based side by side

Side by side

GitFlowTrunk-based development
Long-lived branchesmain + develop (+ release branches during stabilisation)main only (release branches optional, short-lived)
How long a change lives apartdays to weeks (feature branch → develop → release)hours, at most a day or two
Unfinished workstays on its feature branchmerged, but switched off with a feature flag
Releaserelease/* branch, merged into main and develop, taggedtag a commit on main (or cut a release branch from it)
Hotfixhotfix/* from main, merged into main and developfix on main, cherry-pick to a release branch if one exists
Needsdiscipline about the double mergesfast CI, good tests, feature flags, small PRs
Fitsexplicitly versioned software on a release schedule (Driessen's 2020 note); its one main holds only the latest release, so several supported lines need extra long-lived branches the original model doesn't defineweb apps and APIs deployed continuously; with a release branch per supported line, also versioned software such as device firmware
AdvancedFeature flags: how trunk-based teams merge unfinished work

If everything merges to main daily, a half-built feature is on main too. A feature flag (also called a toggle) is a runtime switch that keeps it dark until it's ready:

pairing-screen.js (illustrative, not run)
// The new pairing flow is merged and deployed, but only shown where the flag is on.
export function pairingScreen(user, flags) {
  if (flags.isEnabled("new-pairing-flow", { userId: user.id, tenant: user.tenant })) {
    return renderNewPairingFlow(user);
  }
  return renderLegacyPairing(user);
}
What’s happening
  1. Both code paths ship in the same build. flags.isEnabled asks a flag service or a config file whether this user or tenant should see the new flow.
  2. Developers merge small slices of the new flow every day; with the flag off in production, users never see the half-finished screens, but CI tests both paths.
  3. When it's ready, the team turns the flag on for internal users, then a percentage of customers, then everyone. Turning it off again is the fastest rollback there is: no deploy needed.
  4. The last step is deleting the flag and the old path. Flags are technical debt with a date on them: give each one an owner and a removal ticket, or the codebase fills up with dead branches of if statements.
AdvancedGitHub flow and release branches in a trunk-based team

GitHub flow, the "much simpler workflow" Driessen points to, is trunk-based development with pull requests: branch off main, push, open a pull request, get review and green CI, merge, deploy. Most teams that say "we use trunk-based development" mean this.

Trunk-based doesn't forbid release branches. If you ship something that needs a stabilisation window (a mobile app in store review, firmware on devices), trunkbaseddevelopment.com describes cutting a release branch from trunk just in time, with developers continuing on trunk, fixes made on trunk first and cherry-picked to the branch, and the branch never merged back. That's covered with a runnable example in Release branches, tags and hotfixes.

Pick a strategy

Your team builds a device-management web UI deployed to a cloud service several times a week, and a REST API that the same service exposes. A colleague proposes GitFlow "because it's the standard". What do you say?

Show answer

Propose trunk-based development with short-lived PR branches, and explain why GitFlow's main benefit doesn't apply.

What’s happening
  1. Ask the deciding question: how many versions do we support at the same time? A cloud web UI and API have exactly one version in production (the one deployed), so GitFlow's machinery for parallel versions, develop plus release and hotfix branches with double merges, buys nothing.
  2. Name the costs it would add: features integrate late (at release time), the double merges are easy to forget, and the history is hard to read, as the model's six merge commits for four changes showed.
  3. Offer the alternative concretely: main protected and always releasable, PR branches that live a day or two, CI on every PR, feature flags for work that isn't ready, releases as tags on main.
  4. Leave a door open: if an API version must be supported for older clients, that's handled with API versioning in the code (/v1, /v2), not with long-lived Git branches. A release branch is only needed if something ships on its own cadence, and then it's cut from main and receives cherry-picked fixes.

#Merge, squash and rebase

Added

When a pull request is approved, its commits have to get onto main somehow. Git (and GitHub's merge button) offer three ways, and they leave different histories behind: a merge commit that joins the two lines, a single squashed commit that summarises the branch, or the branch's commits replayed one by one on top of main as if they'd been written today.

A mental model for each:

  • Merge is stapling the branch to main with a cover note (the merge commit). Nothing is rewritten; the original commits keep their hashes, and the graph shows exactly when the branch started and ended.
  • Squash is retyping the branch as one summary paragraph. The branch's individual commits don't reach main at all; one new commit with the combined change does.
  • Rebase is replaying your edits onto today's version of the document. Each of your commits is re-applied on top of the new base, which makes new commits with new hashes, and the history comes out as a straight line.

All three end with the same files on main. What differs is what you can do later: read the history, git revert one change, git bisect to a small commit, or tell which branches are already merged.

merge-squash-rebase.mjs
// One feature branch, integrated three ways. Runs real git in a throwaway repository with fixed
// names and dates, so every hash below is the same on every run (Git 2.54).
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const dir = mkdtempSync(join(tmpdir(), "git-demo-"));
let minute = 0; // fake clock: every git call happens one minute after the last
const git = (...args) => {
  const date = `${1767603600 + 60 * minute++} +0000`; // 2026-01-05 09:00 UTC onwards
  return execFileSync("git", args, {
    cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
    env: { ...process.env, LC_ALL: "C", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1",
      GIT_AUTHOR_NAME: "Asha", GIT_AUTHOR_EMAIL: "asha@example.com", GIT_AUTHOR_DATE: date,
      GIT_COMMITTER_NAME: "Asha", GIT_COMMITTER_EMAIL: "asha@example.com", GIT_COMMITTER_DATE: date },
  }).trimEnd();
};
const commit = (file, text, msg) => { writeFileSync(join(dir, file), text); git("add", file); git("commit", "-q", "-m", msg); };

try {
  git("-c", "init.defaultBranch=main", "init", "-q");
  commit("app.js", "v1\n", "Initial commit");
  git("switch", "-q", "-c", "feature");
  commit("login.js", "form\n", "Add login form");
  commit("login.js", "form\nvalidation\n", "Validate login input");
  git("switch", "-q", "main");
  commit("README.md", "docs\n", "Update README"); // main moved on meanwhile

  git("switch", "-q", "-c", "with-merge", "main");
  git("merge", "-q", "--no-ff", "feature", "-m", "Merge branch 'feature'");
  console.log("1. Merge commit (git merge --no-ff)\n" + git("log", "--graph", "--oneline", "with-merge"));

  git("switch", "-q", "-c", "with-squash", "main");
  git("merge", "-q", "--squash", "feature");
  git("commit", "-q", "-m", "Add login form with validation (#12)");
  console.log("\n2. Squash (git merge --squash + commit)\n" + git("log", "--graph", "--oneline", "with-squash"));

  git("switch", "-q", "-c", "with-rebase", "feature");
  git("rebase", "-q", "main");
  console.log("\n3. Rebase (git rebase main, then fast-forward)\n" + git("log", "--graph", "--oneline", "with-rebase"));

  console.log("\nfeature's own commits are untouched:\n" + git("log", "--oneline", "main..feature"));
  console.log("\nIs feature merged into each result? (git branch --merged)");
  for (const b of ["with-merge", "with-squash", "with-rebase"]) {
    const merged = git("branch", "--merged", b).split("\n").map((l) => l.slice(2));
    console.log(`  ${b.padEnd(12)} ${merged.includes("feature") ? "yes" : "no"}`);
  }
} finally {
  rmSync(dir, { recursive: true, force: true });
}
Result
1. Merge commit (git merge --no-ff)
*   c5f779c Merge branch 'feature'
|\  
| * 058a85c Validate login input
| * 1257655 Add login form
* | f5720b0 Update README
|/  
* 4e9ab7d Initial commit

2. Squash (git merge --squash + commit)
* 6805f10 Add login form with validation (#12)
* f5720b0 Update README
* 4e9ab7d Initial commit

3. Rebase (git rebase main, then fast-forward)
* 7fbcf42 Validate login input
* 538219a Add login form
* f5720b0 Update README
* 4e9ab7d Initial commit

feature's own commits are untouched:
058a85c Validate login input
1257655 Add login form

Is feature merged into each result? (git branch --merged)
  with-merge   yes
  with-squash  no
  with-rebase  no
What’s happening
  1. The setup: main has Initial commit (4e9ab7d). feature branches off and gets two commits, 1257655 Add login form and 058a85c Validate login input. Meanwhile main moves on with f5720b0 Update README, so the branches have diverged: neither contains the other.
  2. Merge (git merge --no-ff) creates c5f779c Merge branch 'feature', a commit with two parents (f5720b0 and 058a85c). The graph shows the fork and the join. The feature's commits keep their original hashes, so anyone who already had them still has the same commits.
  3. Squash (git merge --squash then git commit) applies the branch's combined change to the working tree and index, but doesn't record a merge: the commit you make, 6805f10, has one parent and is the only trace of the branch. The two original commits never reach main. The message is whatever you write; here it's the PR title with its number, a common convention for squash merges.
  4. Rebase (git rebase main) takes the feature's two commits and re-applies them on top of f5720b0. Same messages, same changes, but new hashes: 538219a and 7fbcf42 instead of 1257655 and 058a85c, because the parent (and the committer date) changed and the hash covers both. main can then fast-forward to 7fbcf42. The last section of the output proves the originals still exist untouched on feature.
  5. The final check is the one that bites teams: git branch --merged asks "are this branch's commits reachable from here?". Only the merge commit keeps them reachable. After a squash or a rebase, Git says feature is not merged, because its original commits aren't on main, even though its changes are. That's why git branch -d feature refuses after a squash merge, and why on GitHub you delete the head branch with the PR's "Delete branch" button (or turn on the repository setting that deletes head branches automatically) rather than relying on Git's merged check. Either only removes the branch on GitHub: your local copy still needs git branch -D feature.
AdvancedMerge, squash and rebase compared

What each leaves you with

Merge commitSquashRebase (then fast-forward)
Commits on main per PRthe PR's commits + 1 merge commitexactly 1the PR's commits, rewritten
History shapea graph that shows brancheslinearlinear
Original hashes keptyesno (originals never land)no (new hashes)
Undo the whole PRgit revert -m 1 <merge>git revert <commit> (easiest)revert each commit, or a range
git bisect precisionper original commit (use --first-parent to skip into branches only when needed)per PRper original commit
Needs a clean commit seriesnono (messy WIP commits disappear)yes (every commit lands as is)
GitHub button"Create a merge commit""Squash and merge""Rebase and merge"

GitHub's own documentation ("About merge methods on GitHub", checked October 2026) says its merge-commit button uses --no-ff, and that "Rebase and merge" always updates the committer information and creates new SHAs on GitHub, even in cases where a local git rebase wouldn't. It also notes GitHub can't sign those rebased commits for you. A branch rule or ruleset can require linear history, which blocks merge commits so PRs must be squashed or rebased.

AdvancedFast-forward merges and --no-ff

If main hasn't moved since the branch was created, git merge feature doesn't need a merge commit at all: it just moves the main label forward to the branch's tip. That's a fast-forward, and afterwards history looks as if the work had been done on main directly. The three flags control it:

  • git merge --ff (the default): fast-forward if possible, otherwise make a merge commit.
  • git merge --no-ff: always make a merge commit, even when a fast-forward was possible, so the branch stays visible. GitFlow and GitHub's merge button use this.
  • git merge --ff-only: fast-forward or fail. Useful in scripts and on main locally (git config pull.ff only), because it never creates a surprise merge commit; if it fails, you rebase first.
AdvancedThe golden rule of rebasing, and --force-with-lease

Rebasing rewrites commits. That's harmless on a branch only you have, and destructive on a branch someone else has pulled, because their copy still has the old commits. The rule: never rebase commits other people have built on (in practice: main, release branches, and any branch someone else has pushed to). After rebasing your own pushed PR branch you have to force-push, and --force-with-lease is how to do it safely. This model has Asha rebase a branch that Ravi has quietly pushed to:

force-with-lease.mjs
// Two people on one pushed branch. Asha rebases and force-pushes; --force-with-lease refuses
// to overwrite Ravi's commit that she hasn't seen. Real git with a local bare "origin", Git 2.54.
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const root = mkdtempSync(join(tmpdir(), "git-demo-"));
let minute = 0;
const as = (who, cwd) => (...args) => {
  const date = `${1767603600 + 60 * minute++} +0000`;
  const email = `${who.toLowerCase()}@example.com`;
  return execFileSync("git", args, {
    cwd: join(root, cwd), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
    env: { ...process.env, LC_ALL: "C", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1",
      GIT_AUTHOR_NAME: who, GIT_AUTHOR_EMAIL: email, GIT_AUTHOR_DATE: date,
      GIT_COMMITTER_NAME: who, GIT_COMMITTER_EMAIL: email, GIT_COMMITTER_DATE: date },
  }).trimEnd();
};
const commit = (git, cwd, file, msg) => { writeFileSync(join(root, cwd, file), msg); git("add", file); git("commit", "-q", "-m", msg); };
const lastLine = (err) => err.stderr.trim().split("\n").filter((l) => l.includes("rejected"))[0].trim();

try {
  as("Asha", ".")("-c", "init.defaultBranch=main", "init", "-q", "--bare", "origin.git");
  as("Asha", ".")("clone", "-q", "origin.git", "asha");
  const asha = as("Asha", "asha");
  commit(asha, "asha", "app.js", "chore: initial commit");
  asha("push", "-q", "origin", "HEAD:main");
  asha("switch", "-q", "-c", "feature/pairing");
  commit(asha, "asha", "pair.js", "feat: pairing screen");
  asha("push", "-q", "-u", "origin", "feature/pairing");

  as("Ravi", ".")("clone", "-q", "origin.git", "ravi");
  const ravi = as("Ravi", "ravi");
  ravi("switch", "-q", "feature/pairing");
  commit(ravi, "ravi", "pair-test.js", "test: pairing screen");
  ravi("push", "-q", "origin", "feature/pairing"); // Asha hasn't fetched this

  asha("switch", "-q", "main"); // meanwhile main moves on
  commit(asha, "asha", "README.md", "docs: readme");
  asha("push", "-q", "origin", "main");
  asha("switch", "-q", "feature/pairing");
  asha("rebase", "-q", "main");

  try { asha("push", "-q", "origin", "feature/pairing"); } catch (e) { console.log(`plain push:          ${lastLine(e)}`); }
  try { asha("push", "-q", "--force-with-lease", "origin", "feature/pairing"); } catch (e) { console.log(`--force-with-lease:  ${lastLine(e)}`); }
  asha("fetch", "-q"); // now her lease is fresh, but she still hasn't looked at Ravi's commit
  try { asha("push", "-q", "--force-with-lease", "--force-if-includes", "origin", "feature/pairing"); }
  catch (e) { console.log(`after fetch, --force-with-lease --force-if-includes:  ${lastLine(e)}`); }

  // The right fix: start from what's really on origin, replay it onto main, then push with the lease.
  asha("reset", "-q", "--hard", "origin/feature/pairing");
  asha("rebase", "-q", "main");
  asha("push", "-q", "--force-with-lease", "--force-if-includes", "origin", "feature/pairing");
  console.log("\norigin/feature/pairing after the rebuilt push:\n" + asha("log", "--format=  %h %an: %s", "origin/feature/pairing"));
} finally {
  rmSync(root, { recursive: true, force: true });
}
Result
plain push:          ! [rejected]        feature/pairing -> feature/pairing (fetch first)
--force-with-lease:  ! [rejected]        feature/pairing -> feature/pairing (stale info)
after fetch, --force-with-lease --force-if-includes:  ! [rejected]        feature/pairing -> feature/pairing (remote ref updated since checkout)

origin/feature/pairing after the rebuilt push:
  e749ef2 Ravi: test: pairing screen
  7e91f57 Asha: feat: pairing screen
  3a22314 Asha: docs: readme
  f7a05c6 Asha: chore: initial commit
What’s happening
  1. A local bare repository plays origin. Asha pushes feature/pairing with one commit. Ravi clones it, adds test: pairing screen and pushes. Asha hasn't fetched, so her origin/feature/pairing still points at her own commit.
  2. Asha rebases her branch onto the updated main. Her local branch and the remote have now diverged, so a plain git push is rejected (fetch first): Git won't drop commits on the remote that you don't have.
  3. --force-with-lease is the safe force: "overwrite the remote branch only if it still points where my origin/feature/pairing says". It doesn't (Ravi moved it), so it's rejected as stale info. A bare --force would have succeeded here and silently deleted Ravi's commit.
  4. The lease has a gap: after a git fetch (or an IDE that fetches in the background), the remote-tracking ref is up to date, so --force-with-lease alone would pass even though Asha never looked at Ravi's commit. --force-if-includes (added in Git 2.30, December 2020) closes it by also checking that the remote tip is something her local branch has actually contained; it's rejected with remote ref updated since checkout.
  5. The right recovery is to start from what's really on origin (git reset --hard origin/feature/pairing, which includes Ravi's commit), rebase that onto main, and push with the lease. The final history has all four commits, Ravi's included, on top of the new main.
  6. Lesson: make git push --force-with-lease --force-if-includes your only force-push (an alias helps), and protect main and release branches from force pushes entirely with a branch rule.
What does each strategy do to these hashes?

A PR branch has three commits, a1, b2, c3, and main has moved on since the branch was cut. After merging the PR each way, which of a1, b2, c3 can you find with git log main?

Show answer

Merge commit: all three, unchanged. Squash: none of them. Rebase-and-merge: none of them; three new commits with the same messages.

What’s happening
  1. A merge commit has the branch tip c3 as its second parent, so a1, b2 and c3 all become reachable from main with their original hashes.
  2. A squash creates one new commit whose only parent is the old main tip. The branch's commits aren't parents of anything on main, so none of the three is reachable (they live on only in the PR branch until it's deleted).
  3. A rebase re-applies each commit onto the new base. A commit's hash covers its parent, so a1' gets a new hash, and that changes b2''s parent and therefore its hash, and so on down the chain. The model showed exactly this: 1257655 → 538219a, 058a85c → 7fbcf42.
  4. On GitHub, "Rebase and merge" always creates new SHAs even when the branch is already up to date with main, because it always rewrites the committer, according to GitHub's documentation.

#Commit hygiene and good commit messages

Added

A commit is a unit of change you may have to explain, revert or bisect to months later, so a good history is one where each commit does one thing, builds and passes tests, and says why it exists. Its message is the only documentation that is guaranteed to travel with the code: it's what git log, git blame, release notes and the next person debugging at 2 a.m. will read.

The mental model: write each commit as an entry in the project's logbook for a reader who wasn't there. The diff already says what changed, line by line; the message has to say why, what the alternatives were, and what to watch out for. "fix stuff" and "address review comments" tell that reader nothing.

Two widely used references:

  • Chris Beams, "How to Write a Git Commit Message" (31 August 2014) gives seven rules: separate subject from body with a blank line, limit the subject to 50 characters, capitalise it, no full stop at the end, use the imperative mood ("Add", not "Added"), wrap the body at 72 characters, and use the body to explain what and why rather than how.
  • Conventional Commits 1.0.0 (conventionalcommits.org) structures the subject as <type>[optional scope]: <description>. fix maps to a SemVer PATCH, feat to a MINOR, and a breaking change, marked with ! before the colon or a BREAKING CHANGE: footer, to a MAJOR. Other types (docs, chore, refactor, test, perf…) are allowed and don't bump the version. The widely used @commitlint/config-conventional preset allows build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test, limits the header to 100 characters and forbids sentence-case subjects and a trailing full stop.

The two conflict on capitalisation (Beams capitalises the subject; Conventional Commits teams usually write feat: add… in lower case). Pick one per repository and let a tool enforce it. Here's what a good message looks like in the Conventional Commits style:

A commit message worth reading
fix(api): return 404 instead of 500 for an unknown device id

GET /v1/devices/{id} threw when the id wasn't in the registry, because
findDevice() returns undefined and the handler read .serial from it.
Clients saw a 500 and retried, which tripled load during the outage on
the provisioning page.

Return a problem+json 404 instead, and add a test for the unknown id.
Retries now stop at the first response.

Refs: DEV-1432
What’s happening
  1. The subject line (fix(api): return 404 instead of 500 for an unknown device id) is what git log --oneline, GitHub's commit list and release notes show. The type fix says it's a bug fix (a PATCH release), the scope api says where, and the description is imperative: it completes the sentence "If applied, this commit will…".
  2. The blank line matters: Git and every tool treat the first line as the subject and everything after the blank line as the body. Without it, the whole paragraph becomes one huge subject.
  3. The first body paragraph explains the cause and the impact: what was broken, why (the exact mechanism), and why it mattered (retries tripled load). That's the part a reviewer or a future debugger can't get from the diff.
  4. The second paragraph says what the change does about it and how it's verified (a new test). It stays short; the diff shows the how.
  5. The footer (Refs: DEV-1432) links the ticket in the token: value form Conventional Commits uses for footers, so tools can parse it. A breaking change would add a BREAKING CHANGE: … footer here.
  6. Wrapped at 72 columns, it reads well in a terminal, in git log and in an email patch. Compare with "fix 500 bug": same diff, none of the knowledge.
AdvancedEnforcing the format with a commit-msg hook

Git runs .git/hooks/commit-msg with the path of the message file before it records a commit; a non-zero exit aborts the commit. This model installs a small shell hook in a throwaway repository and tries five messages:

commit-msg-hook.mjs
// A commit-msg hook that enforces Conventional Commits, installed in a throwaway repository and
// exercised with real `git commit` calls (fixed names and dates, Git 2.54).
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync, chmodSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const dir = mkdtempSync(join(tmpdir(), "git-demo-"));
let minute = 0;
const git = (...args) => {
  const date = `${1767603600 + 60 * minute++} +0000`;
  return execFileSync("git", args, {
    cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
    env: { ...process.env, LC_ALL: "C", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1",
      GIT_AUTHOR_NAME: "Asha", GIT_AUTHOR_EMAIL: "asha@example.com", GIT_AUTHOR_DATE: date,
      GIT_COMMITTER_NAME: "Asha", GIT_COMMITTER_EMAIL: "asha@example.com", GIT_COMMITTER_DATE: date },
  }).trimEnd();
};

// The hook: git runs .git/hooks/commit-msg with the path of the message file; non-zero exit aborts.
const hook = `#!/bin/sh
header=$(head -n 1 "$1")
pattern='^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\\([a-z0-9-]+\\))?!?: [a-z].*[^.]$'
if ! printf '%s' "$header" | grep -Eq "$pattern"; then
  echo "commit-msg: header must look like 'type(scope): summary' (got: $header)" >&2
  exit 1
fi
if [ \${#header} -gt 72 ]; then
  echo "commit-msg: header is \${#header} characters; keep it to 72" >&2
  exit 1
fi
`;

try {
  git("-c", "init.defaultBranch=main", "init", "-q");
  writeFileSync(join(dir, ".git/hooks/commit-msg"), hook);
  chmodSync(join(dir, ".git/hooks/commit-msg"), 0o755);

  const tries = [
    "fixed stuff",
    "feat(api): Add device reboot endpoint.",
    "feat(api): add device reboot endpoint",
    "fix(ui)!: rename the volume slider event to volume-change",
    "chore: " + "x".repeat(70),
  ];
  for (const [i, msg] of tries.entries()) {
    writeFileSync(join(dir, "change.txt"), `change ${i}\n`);
    git("add", "change.txt");
    try {
      git("commit", "-q", "-m", msg);
      console.log(`accepted  ${msg}`);
    } catch (err) {
      console.log(`rejected  ${err.stderr.trim()}`);
    }
  }
  console.log("\ngit log --oneline\n" + git("log", "--oneline"));
} finally {
  rmSync(dir, { recursive: true, force: true });
}
Result
rejected  commit-msg: header must look like 'type(scope): summary' (got: fixed stuff)
rejected  commit-msg: header must look like 'type(scope): summary' (got: feat(api): Add device reboot endpoint.)
accepted  feat(api): add device reboot endpoint
accepted  fix(ui)!: rename the volume slider event to volume-change
rejected  commit-msg: header is 77 characters; keep it to 72

git log --oneline
4e31e83 fix(ui)!: rename the volume slider event to volume-change
7581236 feat(api): add device reboot endpoint
What’s happening
  1. The hook reads the first line of the message (head -n 1 "$1") and matches it against type(scope)!: summary, with the same type list as commitlint's conventional preset, a lower-case first letter and no trailing full stop.
  2. fixed stuff has no type, so the hook prints its error to stderr and exits 1; git commit fails, and the model catches the error and prints the hook's message. Nothing is recorded.
  3. feat(api): Add device reboot endpoint. has the right shape but breaks two team rules (capital letter, trailing full stop), so it's rejected too. feat(api): add device reboot endpoint passes.
  4. fix(ui)!: rename the volume slider event to volume-change passes: the ! marks a breaking change, which a release tool will turn into a MAJOR version.
  5. The last message is a valid type but 77 characters long, over the hook's 72-character limit (stricter than commitlint's 100), so it's rejected. git log confirms only the two good commits exist.
  6. Local hooks are a convenience, not a guarantee: they live in .git/hooks, which isn't versioned, and git commit --no-verify skips them. Teams share them with a tool such as husky (see Enforcing standards with tooling, not meetings) and repeat the check in CI on the PR title, where it can't be skipped.
AdvancedFrom commit messages to version numbers and changelogs

The payoff of a parseable format is automation: release tools read the messages since the last tag and decide the next version. This model does the same thing with real tags and git log:

next-version.mjs
// Reads Conventional Commit messages since the last tag from a real repository and works out the
// next Semantic Version and a changelog, the way release tools do (fixed names and dates, Git 2.54).
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const dir = mkdtempSync(join(tmpdir(), "git-demo-"));
let minute = 0;
const git = (...args) => {
  const date = `${1767603600 + 60 * minute++} +0000`;
  return execFileSync("git", args, {
    cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
    env: { ...process.env, LC_ALL: "C", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1",
      GIT_AUTHOR_NAME: "Asha", GIT_AUTHOR_EMAIL: "asha@example.com", GIT_AUTHOR_DATE: date,
      GIT_COMMITTER_NAME: "Asha", GIT_COMMITTER_EMAIL: "asha@example.com", GIT_COMMITTER_DATE: date },
  }).trimEnd();
};
let n = 0;
const commit = (msg) => { writeFileSync(join(dir, "log.txt"), `${n++}\n`); git("add", "."); git("commit", "-q", "-m", msg); };

function nextVersion(current, messages) {
  let [major, minor, patch] = current.replace(/^v/, "").split(".").map(Number);
  const breaking = messages.some((m) => /^\w+(\(.+\))?!:/.test(m) || /^BREAKING[ -]CHANGE: /m.test(m));
  if (breaking) return `v${major + 1}.0.0`;
  if (messages.some((m) => /^feat(\(.+\))?:/.test(m))) return `v${major}.${minor + 1}.0`;
  if (messages.some((m) => /^fix(\(.+\))?:/.test(m))) return `v${major}.${minor}.${patch + 1}`;
  return current; // docs, chore, test… alone don't need a release
}

try {
  git("-c", "init.defaultBranch=main", "init", "-q");
  commit("feat: first release");
  git("tag", "-a", "v1.4.2", "-m", "Release v1.4.2");
  const batches = [
    ["docs: explain the retry settings", "fix(api): return 404 for an unknown device id"],
    ["feat(ui): show the camera preview on the settings page", "test: cover the preview toggle"],
    ["refactor(api): split the device router\n\nBREAKING CHANGE: /v1/device is now /v1/devices"],
  ];
  for (const batch of batches) {
    for (const msg of batch) commit(msg);
    const last = git("describe", "--tags", "--abbrev=0");
    const messages = git("log", "--format=%B%x00", `${last}..HEAD`).split("\0").map((m) => m.trim()).filter(Boolean);
    const next = nextVersion(last, messages);
    console.log(`since ${last}: ${messages.map((m) => m.split(/[(:!]/)[0]).join(", ")} -> ${next}`);
    for (const m of messages) console.log(`  - ${m.split("\n")[0]}`);
    git("tag", "-a", next, "-m", `Release ${next}`);
  }
  console.log("\ngit tag -n\n" + git("tag", "-n"));
} finally {
  rmSync(dir, { recursive: true, force: true });
}
Result
since v1.4.2: fix, docs -> v1.4.3
  - fix(api): return 404 for an unknown device id
  - docs: explain the retry settings
since v1.4.3: test, feat -> v1.5.0
  - test: cover the preview toggle
  - feat(ui): show the camera preview on the settings page
since v1.5.0: refactor -> v2.0.0
  - refactor(api): split the device router

git tag -n
v1.4.2          Release v1.4.2
v1.4.3          Release v1.4.3
v1.5.0          Release v1.5.0
v2.0.0          Release v2.0.0
What’s happening
  1. The repository starts at an annotated tag v1.4.2. For each batch of commits, the model asks Git for the latest tag (git describe --tags --abbrev=0) and for the full messages since it (git log --format=%B%x00 v1.4.2..HEAD, NUL-separated so multi-line bodies stay together).
  2. Batch one has a fix and a docs commit. No feature, no breaking change, so it's a PATCH: v1.4.2 → v1.4.3. The docs commit is listed but doesn't affect the number.
  3. Batch two has a feat, so MINOR: v1.4.3 → v1.5.0 (the patch number resets to 0, as Semantic Versioning 2.0.0 requires).
  4. Batch three is only a refactor, but its body has a BREAKING CHANGE: footer, so MAJOR: v2.0.0. This is why the footer matters: the subject alone looked harmless.
  5. git tag -n lists the annotated tags with their messages. Real tools (semantic-release, release-please, changesets and others) do the same parsing, then also write the changelog and publish, so a wrong commit type is a wrong release.
AdvancedCleaning up a branch before review with fixup commits

Review feedback usually lands as "address comments" commits. If the team rebase-merges (so every commit lands on main), fold those into the commits they fix. git commit --fixup <commit> records a commit titled fixup! <subject>, and git rebase -i --autosquash moves each fixup under its target and squashes it in:

fixup-autosquash.mjs
// Review feedback as fixup commits, then folded into the commits they fix before merging:
// git commit --fixup + git rebase --autosquash (non-interactive), Git 2.54, fixed names and dates.
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const dir = mkdtempSync(join(tmpdir(), "git-demo-"));
let minute = 0;
const git = (...args) => {
  const date = `${1767603600 + 60 * minute++} +0000`;
  return execFileSync("git", args, {
    cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
    env: { ...process.env, LC_ALL: "C", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1",
      GIT_SEQUENCE_EDITOR: "true", // accept the todo list as git wrote it (no editor opens)
      GIT_AUTHOR_NAME: "Asha", GIT_AUTHOR_EMAIL: "asha@example.com", GIT_AUTHOR_DATE: date,
      GIT_COMMITTER_NAME: "Asha", GIT_COMMITTER_EMAIL: "asha@example.com", GIT_COMMITTER_DATE: date },
  }).trimEnd();
};
const write = (file, text) => { writeFileSync(join(dir, file), text); git("add", file); };

try {
  git("-c", "init.defaultBranch=main", "init", "-q");
  write("app.js", "app\n");
  git("commit", "-q", "-m", "chore: initial commit");
  git("switch", "-q", "-c", "feature/reboot");
  write("api.js", "router.post('/reboot', reboot)\n");
  git("commit", "-q", "-m", "feat(api): add device reboot endpoint");
  const apiCommit = git("rev-parse", "--short", "HEAD");
  write("ui.js", "button('Reboot')\n");
  git("commit", "-q", "-m", "feat(ui): add reboot button");

  // Review comments: "the endpoint needs auth", "confirm before rebooting".
  write("api.js", "router.post('/reboot', requireAdmin, reboot)\n");
  git("commit", "-q", "--fixup", apiCommit);
  write("ui.js", "button('Reboot', { confirm: true })\n");
  git("commit", "-q", "--fixup", "HEAD~1");
  console.log("Branch during review:\n" + git("log", "--oneline", "main..HEAD"));

  git("rebase", "-q", "-i", "--autosquash", "main");
  console.log("\nAfter git rebase -i --autosquash main:\n" + git("log", "--oneline", "main..HEAD"));
  console.log("\nThe API commit now contains the fix:\n" + git("show", "--format=%s", "HEAD~1", "--", "api.js").split("\n").filter((l) => /^[+-]r|^feat/.test(l)).join("\n"));
} finally {
  rmSync(dir, { recursive: true, force: true });
}
Result
Branch during review:
14856d8 fixup! feat(ui): add reboot button
512237d fixup! feat(api): add device reboot endpoint
e0aa05d feat(ui): add reboot button
f90ead4 feat(api): add device reboot endpoint

After git rebase -i --autosquash main:
dd04a25 feat(ui): add reboot button
7f40585 feat(api): add device reboot endpoint

The API commit now contains the fix:
feat(api): add device reboot endpoint
+router.post('/reboot', requireAdmin, reboot)
What’s happening
  1. The branch has two real commits, f90ead4 feat(api): … and e0aa05d feat(ui): …. Review asks for auth on the endpoint and a confirmation on the button.
  2. Each fix is committed with --fixup: Git titles them fixup! feat(api): add device reboot endpoint and fixup! feat(ui): add reboot button, so they're easy to review on their own ("what changed since my last review?").
  3. git rebase -i --autosquash main builds the rebase todo list with each fixup! commit moved directly under its target and marked fixup. GIT_SEQUENCE_EDITOR=true accepts that list without opening an editor, which is what lets a script (or you, with rebase.autoSquash set) run it non-interactively.
  4. The result is two clean commits with new hashes (7f40585, dd04a25), and git show confirms the API commit now contains requireAdmin directly. Nobody reading main later sees the back-and-forth.
  5. Because this rewrites the branch, push it with --force-with-lease, and only on your own PR branch. If the team squash-merges, skip this entirely: the squash does the clean-up for you.
AdvancedAtomic commits in practice
  • Stage parts of files with git add -p, so a formatting change and a bug fix in the same file become two commits.
  • Separate refactors from behaviour changes. "Move the function" and "change what it does" in one commit make the diff unreadable and the revert dangerous.
  • Every commit should build. A commit that doesn't compile is a commit git bisect has to skip (see Finding the commit that broke it with git bisect).
  • Don't commit generated files, secrets or local config. A .gitignore and a secret scanner in CI catch most of it; a leaked secret must be rotated, because deleting it in a later commit leaves it in history.
Which version does this release get?

The last release is v3.2.1. Since then: docs: fix typo in README, fix(ui): keep focus on the dialog, feat(api): add GET /v1/devices/{id}/logs, and perf(api)!: drop the deprecated ?expand=all parameter. What's the next version under Conventional Commits and SemVer?

Show answer

v4.0.0.

What’s happening
  1. Check for breaking changes first, because they win: perf(api)!: has a ! before the colon, which marks a breaking change regardless of the type. One breaking change means MAJOR.
  2. MAJOR increments 3 → 4 and resets the others: 4.0.0. The feat (MINOR) and fix (PATCH) are included in the release but don't add anything on top.
  3. Without the ! commit, the feat would make it v3.3.0; with only the fix and docs, v3.2.2; with only docs, no release is needed at all.
  4. Note it's a perf commit that broke the API: removing a parameter is breaking whatever the motivation, which is exactly why the marker exists separately from the type.

#Release branches, tags and hotfixes

Added

A release is a specific commit you've decided to ship and support. Git gives you two tools to name it. A tag marks one commit forever: v2.3.0 always means exactly that snapshot, which is what you want for "what's running on the devices in the field?". A release branch is a line of development that starts at a release and receives only fixes, so you can ship v2.3.1 without also shipping everything main has gained since.

The mental model: a tag is a label glued onto one page of the project's history; a branch is a sticky note you move forward as you write. You glue a label on every release so you can always find it again; you only start a separate line of sticky notes (a release branch) when you need to patch an old release while new work continues on main.

Version numbers usually follow Semantic Versioning 2.0.0 (semver.org): MAJOR.MINOR.PATCH, where MAJOR changes for incompatible API changes, MINOR for backwards-compatible features and PATCH for backwards-compatible fixes; 0.y.z means initial development, where anything may change. For a web app that's deployed continuously, the version is mostly a label; for an API other teams call, or software installed on devices, it's a promise.

The rule that keeps releases sane, as trunkbaseddevelopment.com puts it for "branch for release": fix on trunk first, then cherry-pick the fix to the release branch; developers don't commit to release branches, and release branches are never merged back. This model does exactly that:

main release/2.3 v2.3.0 feat: sortable columns fix: no serial number fixed on main first cherry-pick -x v2.3.1 only fixes, never merged back
Branch for release: the fix is made on main and copied to the release branch; the new feature stays on main.
release-hotfix.mjs
// Branch for release, fix on main first, cherry-pick the fix to the release branch, tag the patch.
// Real git in a throwaway repository with fixed names and dates (deterministic hashes, Git 2.54).
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const dir = mkdtempSync(join(tmpdir(), "git-demo-"));
let minute = 0;
const git = (...args) => {
  const date = `${1767603600 + 60 * minute++} +0000`;
  return execFileSync("git", args, {
    cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
    env: { ...process.env, LC_ALL: "C", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1",
      GIT_AUTHOR_NAME: "Asha", GIT_AUTHOR_EMAIL: "asha@example.com", GIT_AUTHOR_DATE: date,
      GIT_COMMITTER_NAME: "Asha", GIT_COMMITTER_EMAIL: "asha@example.com", GIT_COMMITTER_DATE: date },
  }).trimEnd();
};
const commit = (file, text, msg) => { writeFileSync(join(dir, file), text); git("add", file); git("commit", "-q", "-m", msg); };

try {
  git("-c", "init.defaultBranch=main", "init", "-q");
  commit("api.js", "devices\n", "feat(api): list devices");
  commit("ui.js", "table\n", "feat(ui): device table");
  git("branch", "release/2.3");                         // cut the release branch
  git("tag", "-a", "v2.3.0", "-m", "Release 2.3.0", "release/2.3");
  commit("ui.js", "table\nsorting\n", "feat(ui): sortable columns"); // main keeps moving
  commit("api.js", "devices\nnull-safe serial\n", "fix(api): handle a device with no serial number");
  const fix = git("rev-parse", "--short", "HEAD");

  git("switch", "-q", "release/2.3");
  git("cherry-pick", "-x", fix);                        // only the fix travels, not the new feature
  git("tag", "-a", "v2.3.1", "-m", "Release 2.3.1");
  git("switch", "-q", "main");

  console.log(git("log", "--graph", "--oneline", "--decorate", "--all"));
  console.log("\nCherry-picked commit message:\n" + git("log", "-1", "--format=%B", "release/2.3"));
  console.log("\ngit describe on each branch:");
  console.log(`  main         ${git("describe", "--tags", "main")}`);
  console.log(`  release/2.3  ${git("describe", "--tags", "release/2.3")}`);
  console.log(`\nWhat shipped in 2.3.1 (git log --oneline v2.3.0..v2.3.1):\n  ${git("log", "--oneline", "v2.3.0..v2.3.1")}`);
  console.log(`\nmain commits already on release/2.3? (git cherry -v release/2.3 main)\n${git("cherry", "-v", "release/2.3", "main")}`);
  git("tag", "quick-mark");                            // a lightweight tag, for comparison
  console.log(`\nv2.3.1 is a ${git("cat-file", "-t", "v2.3.1")} object; quick-mark is a ${git("cat-file", "-t", "quick-mark")}`);
} finally {
  rmSync(dir, { recursive: true, force: true });
}
Result
* 5f2b079 (tag: v2.3.1, release/2.3) fix(api): handle a device with no serial number
| * c4784aa (HEAD -> main) fix(api): handle a device with no serial number
| * 0c80608 feat(ui): sortable columns
|/  
* 1e40c15 (tag: v2.3.0) feat(ui): device table
* 0aeb04a feat(api): list devices

Cherry-picked commit message:
fix(api): handle a device with no serial number

(cherry picked from commit c4784aa8fb0eebb32aaedcc0ac4b42e01bd4d0b1)

git describe on each branch:
  main         v2.3.0-2-gc4784aa
  release/2.3  v2.3.1

What shipped in 2.3.1 (git log --oneline v2.3.0..v2.3.1):
  5f2b079 fix(api): handle a device with no serial number

main commits already on release/2.3? (git cherry -v release/2.3 main)
+ 0c806087751249e48c2333940e3f5a934460cfcf feat(ui): sortable columns
- c4784aa8fb0eebb32aaedcc0ac4b42e01bd4d0b1 fix(api): handle a device with no serial number

v2.3.1 is a tag object; quick-mark is a commit
What’s happening
  1. Two features land on main. git branch release/2.3 cuts the release branch at that commit, and git tag -a v2.3.0 -m "Release 2.3.0" release/2.3 puts an annotated tag on it (1e40c15). Annotated tags are real objects with a tagger, a date and a message, so they're what you use for releases.
  2. main keeps moving: 0c80608 feat(ui): sortable columns, then a production bug is fixed on main as c4784aa fix(api): handle a device with no serial number. Fixing on main first guarantees the next release can't regress, because the fix is already in the line every future release is cut from.
  3. On release/2.3, git cherry-pick -x c4784aa copies only that commit's change, making a new commit 5f2b079 on the release branch; the feature commit doesn't come along. -x appends (cherry picked from commit c4784aa…) to the message, which links the two copies for anyone auditing what's in the patch.
  4. Tagging that commit v2.3.1 completes the hotfix. The graph shows the result: the release branch has exactly one extra commit, and git log v2.3.0..v2.3.1 lists exactly what shipped in the patch, which is what you paste into release notes.
  5. git describe --tags names any commit relative to the nearest tag: main is v2.3.0-2-gc4784aa (two commits after v2.3.0, at c4784aa), and release/2.3 is exactly v2.3.1. Build scripts often stamp this into the app's "About" screen.
  6. git cherry -v release/2.3 main answers "which commits on main are still missing from the release branch?": + means missing (the feature, correctly), - means an equivalent change is already there (the fix, matched by its patch content even though the hashes differ). Finally, git cat-file -t shows the difference between tags: v2.3.1 is a tag object; the lightweight quick-mark is just a name pointing straight at a commit.
AdvancedTrunk-based vs GitFlow hotfixes

Two ways to do hotfixes

Trunk-based "branch for release"GitFlow hotfix/*
Where the fix is writtenon main, with a teston a hotfix/* branch cut from main (the production branch)
How it reaches the releasegit cherry-pick -x onto release/x.ythe hotfix branch is merged into main and tagged
How it reaches future releasesit's already on maina second merge into develop (or into the open release branch)
Release branch merged back?neveryes, release branches merge into main and develop
Main riskforgetting the cherry-pick (customers don't get the fix)forgetting the second merge (the bug returns in the next release)
AdvancedAnnotated, lightweight and signed tags
CommandWhat it storesUse it for
Lightweightgit tag quick-markjust a name pointing at a commitpersonal bookmarks, temporary markers
Annotatedgit tag -a v2.3.1 -m "Release 2.3.1"a tag object: tagger, date, message, then the commitevery release
Signedgit tag -s v2.3.1 -m "…"an annotated tag plus a cryptographic signaturereleases where provenance matters

Tags aren't pushed by git push on its own; push them explicitly (git push origin v2.3.1) or with git push --follow-tags, which pushes annotated tags reachable from the commits being pushed. Treat release tags as immutable: moving a published tag (git tag -f) means two people can have different code under the same version number. GitHub repository rulesets can target tags as well as branches, so release tags can be protected from being moved or deleted.

AdvancedRelease from a tag, branch only when you need to

trunkbaseddevelopment.com notes that many continuous-delivery teams don't cut release branches at all: they release from a tag on main and roll forward with the next release when there's a bug. A useful middle ground is to tag every release and create the release branch retroactively, only when a patch is needed: git switch -c release/2.3 v2.3.0. Until then, the tag is all you need. The same site points out that before you delete an old release branch, the released commit must still have a tag on it, or its commits can eventually be garbage-collected.

Which commits does the patch release get?

release/4.1 was cut at v4.1.0. Since then main has gained feat: device groups, fix: crash when a group is empty (which only affects the new groups feature), and fix: wrong time zone in call logs (a bug that exists in 4.1.0). Which commits should be cherry-picked to make v4.1.1?

Show answer

Only fix: wrong time zone in call logs.

What’s happening
  1. A release branch only receives fixes for bugs that exist in that release. The feature commit is new functionality, so it waits for v4.2.0.
  2. fix: crash when a group is empty fixes code that only exists after feat: device groups; 4.1.x doesn't have device groups, so the bug isn't in the release, and cherry-picking it would probably conflict or bring in half a feature.
  3. fix: wrong time zone in call logs fixes a bug present in 4.1.0, so it's cherry-picked with -x onto release/4.1, the branch's CI runs, and the result is tagged v4.1.1.
  4. Afterwards git cherry -v release/4.1 main should list the time-zone fix with - (already there) and the other two with + (correctly missing).

#Resolving conflicts and recovering mistakes

Added

A merge conflict happens when two branches changed the same lines (or one changed a file the other deleted) and Git can't tell which intent should win. Git merges everything it can, stops, marks the conflicting regions in the files, and waits for a human. A conflict isn't an error; it's Git asking a question only the authors can answer.

The mental model: two editors rewrote the same sentence in their own copies. Git shows you both versions, and with merge.conflictStyle=zdiff3 (added in Git 2.35, January 2022, according to GitHub's "Highlights from Git 2.35") it also shows the original sentence they both started from. With the original in front of you, you can see what each side changed, which is what you actually need to combine them.

The other half of this topic is undoing things. Almost nothing in Git is truly lost once it's been committed: commits that no branch points at any more are still in the repository for a while, and the reflog records where HEAD and each branch have been. The tools differ in whether they add history (git revert, safe on shared branches) or rewrite it (git reset, only for your own unpushed or private work).

conflict.mjs
// Two branches change the same line; git stops and shows both sides plus the common ancestor
// (merge.conflictStyle=zdiff3). Real git, fixed names and dates, Git 2.54.
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const dir = mkdtempSync(join(tmpdir(), "git-demo-"));
let minute = 0;
const git = (...args) => {
  const date = `${1767603600 + 60 * minute++} +0000`;
  return execFileSync("git", ["-c", "merge.conflictStyle=zdiff3", ...args], {
    cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
    env: { ...process.env, LC_ALL: "C", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1",
      GIT_AUTHOR_NAME: "Asha", GIT_AUTHOR_EMAIL: "asha@example.com", GIT_AUTHOR_DATE: date,
      GIT_COMMITTER_NAME: "Asha", GIT_COMMITTER_EMAIL: "asha@example.com", GIT_COMMITTER_DATE: date },
  }).trimEnd();
};
const save = (text, msg) => { writeFileSync(join(dir, "config.js"), text); git("add", "config.js"); git("commit", "-q", "-m", msg); };

try {
  git("-c", "init.defaultBranch=main", "init", "-q");
  save("export const config = {\n  timeoutMs: 5000,\n  retries: 2,\n};\n", "chore: add client config");
  git("switch", "-q", "-c", "feature/slow-networks");
  save("export const config = {\n  timeoutMs: 15000,\n  retries: 2,\n};\n", "fix: allow 15 s on slow networks");
  git("switch", "-q", "main");
  save("export const config = {\n  timeoutMs: 8000, // p99 is 6.1 s\n  retries: 3,\n};\n", "perf: tune timeout and retries");

  try {
    git("merge", "feature/slow-networks");
  } catch (err) {
    console.log(err.stdout.trim());
  }
  console.log("\ngit status --short: " + git("status", "--short"));
  console.log("\nconfig.js now contains:\n" + readFileSync(join(dir, "config.js"), "utf8"));

  // Resolve: talk to the author, then write the combined intent, not "pick one side".
  writeFileSync(join(dir, "config.js"), "export const config = {\n  timeoutMs: 15000, // slow networks; p99 is 6.1 s\n  retries: 3,\n};\n");
  git("add", "config.js");
  git("commit", "-q", "--no-edit");
  console.log(git("log", "--graph", "--oneline"));
} finally {
  rmSync(dir, { recursive: true, force: true });
}
Result
Auto-merging config.js
CONFLICT (content): Merge conflict in config.js
Automatic merge failed; fix conflicts and then commit the result.

git status --short: UU config.js

config.js now contains:
export const config = {
<<<<<<< HEAD
  timeoutMs: 8000, // p99 is 6.1 s
  retries: 3,
||||||| 808af32
  timeoutMs: 5000,
  retries: 2,
=======
  timeoutMs: 15000,
  retries: 2,
>>>>>>> feature/slow-networks
};

*   40e68db Merge branch 'feature/slow-networks'
|\  
| * b75f123 fix: allow 15 s on slow networks
* | 3d56083 perf: tune timeout and retries
|/  
* 808af32 chore: add client config
What’s happening
  1. config.js starts with timeoutMs: 5000, retries: 2 (808af32). The feature branch raises the timeout to 15000 for slow networks; main, meanwhile, tunes the same lines to timeoutMs: 8000 and retries: 3. Both sides touched the same two lines, so git merge stops with CONFLICT (content) and exits non-zero (the model catches it and prints Git's own message).
  2. git status --short shows UU config.js: unmerged, modified by us and us (both sides). Nothing is committed yet; the merge is in progress until you resolve and commit, or abort with git merge --abort.
  3. The file shows three sections. Between <<<<<<< HEAD and ||||||| is ours (the branch you're on, main). Between ||||||| 808af32 and ======= is the common ancestor, the zdiff3 addition. Between ======= and >>>>>>> feature/slow-networks is theirs. The unchanged first and last lines stay outside the markers.
  4. Reading it with the ancestor: main changed the timeout and the retries; the feature changed only the timeout. So the resolution keeps main's retries: 3 and has to decide the timeout, which is a product question: here, after talking to the author, 15000 with a comment saying why. Picking "ours" or "theirs" wholesale would have silently dropped one person's intent.
  5. git add config.js marks it resolved and git commit --no-edit records the merge with Git's default message. The graph shows the ordinary merge shape. Then run the tests: a conflict resolution is new code nobody has reviewed.
AdvancedUndo commands cheat sheet

Undo cheat sheet

SituationCommandRewrites history?
Undo a pushed commit on a shared branchgit revert <commit>no, adds an inverse commit
Undo a merged PR (merge commit)git revert -m 1 <merge-commit>no
Uncommit your last local commit, keep the changes stagedgit reset --soft HEAD~1yes (local only)
Uncommit, keep the changes in files but unstagedgit reset HEAD~1 (--mixed, the default)yes (local only)
Throw away local commits and changesgit reset --hard <commit>yes, and discards uncommitted work
Discard changes to one filegit restore <file>no
Unstage a filegit restore --staged <file>no
Fix the last commit's message or add a forgotten filegit commit --amendyes (don't amend pushed commits)
Get back something you reset, rebased or deletedgit reflog, then git reset --hard <hash> or git branch <name> <hash>moves the branch back
Abort a merge or rebase in progressgit merge --abort, git rebase --abortno
AdvancedRevert vs reset, and the reflog safety net

This model starts from the same three commits twice: once it reverts the bad commit, once it resets past it, and then it uses the reflog to undo the reset.

revert-vs-reset.mjs
// A bad commit reached main. Undo it two ways and compare: git revert (new commit, history kept)
// vs git reset --hard (history rewritten). Then recover the "lost" commits from the reflog.
// Real git, fixed names and dates, Git 2.54.
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const dir = mkdtempSync(join(tmpdir(), "git-demo-"));
let minute = 0;
const git = (...args) => {
  const date = `${1767603600 + 60 * minute++} +0000`;
  return execFileSync("git", args, {
    cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
    env: { ...process.env, LC_ALL: "C", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1",
      GIT_AUTHOR_NAME: "Asha", GIT_AUTHOR_EMAIL: "asha@example.com", GIT_AUTHOR_DATE: date,
      GIT_COMMITTER_NAME: "Asha", GIT_COMMITTER_EMAIL: "asha@example.com", GIT_COMMITTER_DATE: date },
  }).trimEnd();
};
const commit = (file, text, msg) => { writeFileSync(join(dir, file), text); git("add", file); git("commit", "-q", "-m", msg); };
const read = (file) => readFileSync(join(dir, file), "utf8").trim();

try {
  git("-c", "init.defaultBranch=main", "init", "-q");
  commit("limits.js", "maxUpload = 10", "feat: upload limit");
  commit("limits.js", "maxUpload = 0", "fix: tighten upload limit"); // the bad one: blocks every upload
  commit("README.md", "docs", "docs: document limits");
  git("branch", "before"); // remember this state so both experiments start from it

  git("switch", "-q", "-c", "with-revert", "before");
  git("revert", "--no-edit", "HEAD~1");
  console.log("git revert HEAD~1  (safe on a shared branch)\n" + git("log", "--oneline"));
  console.log(`limits.js: ${read("limits.js")}`);

  git("switch", "-q", "-c", "with-reset", "before");
  git("reset", "-q", "--hard", "HEAD~2");
  console.log("\ngit reset --hard HEAD~2  (rewrites history; also threw away the docs commit)\n" + git("log", "--oneline"));
  console.log(`limits.js: ${read("limits.js")}`);

  console.log("\ngit reflog -4  (where HEAD has been)\n" + git("reflog", "-4"));
  git("reset", "-q", "--hard", "with-reset@{1}"); // the branch's previous position, from its own reflog
  console.log("\nAfter git reset --hard with-reset@{1}\n" + git("log", "--oneline"));
} finally {
  rmSync(dir, { recursive: true, force: true });
}
Result
git revert HEAD~1  (safe on a shared branch)
d3e9fcc Revert "fix: tighten upload limit"
bc22dca docs: document limits
2440ebc fix: tighten upload limit
bbb52f1 feat: upload limit
limits.js: maxUpload = 10

git reset --hard HEAD~2  (rewrites history; also threw away the docs commit)
bbb52f1 feat: upload limit
limits.js: maxUpload = 10

git reflog -4  (where HEAD has been)
bbb52f1 HEAD@{0}: reset: moving to HEAD~2
bc22dca HEAD@{1}: checkout: moving from with-revert to with-reset
d3e9fcc HEAD@{2}: revert: Revert "fix: tighten upload limit"
bc22dca HEAD@{3}: checkout: moving from main to with-revert

After git reset --hard with-reset@{1}
bc22dca docs: document limits
2440ebc fix: tighten upload limit
bbb52f1 feat: upload limit
What’s happening
  1. History is bbb52f1 feat: upload limit (maxUpload = 10), 2440ebc fix: tighten upload limit (the bad one: maxUpload = 0 blocks every upload), and bc22dca docs: document limits, which came after it.
  2. git revert HEAD~1 creates d3e9fcc Revert "fix: tighten upload limit", a new commit that applies the inverse diff. The file is back to maxUpload = 10, all four commits are in history, and the later docs commit is untouched. Anyone who pulls just gets one more commit: this is safe on main.
  3. git reset --hard HEAD~2 moves the branch back to bbb52f1, so the file is also maxUpload = 10, but the history now has one commit. It removed the docs commit too, because reset can only cut history at a point, not remove one commit from the middle. If this branch had been pushed, everyone else would still have the "deleted" commits and the next push would need a force.
  4. git reflog shows every position HEAD has had, newest first: the reset (HEAD@{0}), the checkout before it, the revert, and so on. The commits the reset "removed" are still there and named in it.
  5. git reset --hard with-reset@{1} uses the branch's own reflog ("where with-reset was one move ago") and puts it back at bc22dca with all three commits. Recovery works the same after a bad rebase or a deleted branch: find the hash in git reflog, then git reset --hard <hash> or git branch <name> <hash>.
  6. The reflog is local and temporary: it isn't pushed, and by default git gc expires entries after 90 days (gc.reflogExpire), or 30 days for entries no longer reachable from the branch tip (gc.reflogExpireUnreachable), according to the git-config documentation for Git 2.54. Recover promptly.
AdvancedWhat reset --soft, --mixed and --hard each touch

What reset --soft, --mixed and --hard each touch

git reset <commit> always moves the current branch to <commit>. The mode decides what happens to the index (the staging area) and the working tree (your files):

reset-modes.mjs
// What git reset --soft, --mixed and --hard each do to the branch, the index and your files.
// Real git, fixed names and dates, Git 2.54.
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const dir = mkdtempSync(join(tmpdir(), "git-demo-"));
let minute = 0;
const git = (...args) => {
  const date = `${1767603600 + 60 * minute++} +0000`;
  return execFileSync("git", args, {
    cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
    env: { ...process.env, LC_ALL: "C", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1",
      GIT_AUTHOR_NAME: "Asha", GIT_AUTHOR_EMAIL: "asha@example.com", GIT_AUTHOR_DATE: date,
      GIT_COMMITTER_NAME: "Asha", GIT_COMMITTER_EMAIL: "asha@example.com", GIT_COMMITTER_DATE: date },
  }).trimEnd();
};
const commit = (text, msg) => { writeFileSync(join(dir, "notes.txt"), text); git("add", "."); git("commit", "-q", "-m", msg); };

try {
  git("-c", "init.defaultBranch=main", "init", "-q");
  commit("one\n", "first");
  commit("one\ntwo\n", "second");
  git("branch", "start");
  for (const mode of ["--soft", "--mixed", "--hard"]) {
    git("switch", "-q", "-f", "-C", "try", "start");
    git("reset", "-q", mode, "HEAD~1");
    const status = git("status", "--short") || "(clean)";
    const file = readFileSync(join(dir, "notes.txt"), "utf8").trim().replace("\n", " ");
    console.log(`${mode.padEnd(8)} HEAD -> ${git("log", "-1", "--format=%s")}   status: ${status.padEnd(12)} notes.txt: ${file}`);
  }
} finally {
  rmSync(dir, { recursive: true, force: true });
}
Result
--soft   HEAD -> first   status: M  notes.txt notes.txt: one two
--mixed  HEAD -> first   status:  M notes.txt notes.txt: one two
--hard   HEAD -> first   status: (clean)      notes.txt: one
What’s happening
  1. Each run starts from the same two commits, first (notes.txt = one) and second (one two), and resets one commit back to first.
  2. --soft moves only the branch. The index and files still hold second's content, so git status shows M in the first column: the change is staged, ready to recommit. Use it to squash your last few local commits into one: git reset --soft HEAD~3 && git commit.
  3. --mixed (the default) moves the branch and resets the index, but leaves your files alone: M in the second column means modified but unstaged. Use it to "uncommit and re-stage differently", for example to split a commit with git add -p.
  4. --hard moves the branch, the index and the working tree: the status is clean and the file is back to one. Uncommitted changes are gone for good (they were never commits, so the reflog can't bring them back). That's the one reset to think twice about.
AdvancedReverting a merge, and merging it again

Reverting a merged PR, then merging it again

Reverting a merge commit works, but it has a famous trap: Git now believes the branch's commits are already merged, so merging the fixed branch later brings only the new commits.

revert-merge.mjs
// Reverting a merged pull request, then trying to merge the fixed branch again: the classic trap.
// Real git, fixed names and dates, Git 2.54.
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const dir = mkdtempSync(join(tmpdir(), "git-demo-"));
let minute = 0;
const git = (...args) => {
  const date = `${1767603600 + 60 * minute++} +0000`;
  return execFileSync("git", args, {
    cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
    env: { ...process.env, LC_ALL: "C", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1",
      GIT_AUTHOR_NAME: "Asha", GIT_AUTHOR_EMAIL: "asha@example.com", GIT_AUTHOR_DATE: date,
      GIT_COMMITTER_NAME: "Asha", GIT_COMMITTER_EMAIL: "asha@example.com", GIT_COMMITTER_DATE: date },
  }).trimEnd();
};
const commit = (file, text, msg) => { writeFileSync(join(dir, file), text); git("add", file); git("commit", "-q", "-m", msg); };
const has = (file) => (existsSync(join(dir, file)) ? "present" : "missing");

try {
  git("-c", "init.defaultBranch=main", "init", "-q");
  commit("app.js", "app\n", "chore: initial commit");
  git("switch", "-q", "-c", "feature/export");
  commit("export.js", "export v1\n", "feat: CSV export");
  git("switch", "-q", "main");
  commit("app.js", "app\nmenu\n", "feat: menu");
  git("merge", "-q", "--no-ff", "feature/export", "-m", "Merge pull request #7 from feature/export");

  // Production breaks. Revert the merge: -m 1 means "keep parent 1 (main), undo what parent 2 brought".
  git("revert", "--no-edit", "-m", "1", "HEAD");
  console.log(`after revert -m 1: export.js ${has("export.js")}, escape.js ${has("escape.js")}`);

  // The author fixes the bug on the same branch and asks for it to be merged again.
  git("switch", "-q", "feature/export");
  commit("escape.js", "escape commas\n", "fix: escape commas in CSV export");
  git("switch", "-q", "main");
  git("merge", "-q", "--no-ff", "feature/export", "-m", "Merge pull request #9 from feature/export");
  console.log(`merge again:       export.js ${has("export.js")}, escape.js ${has("escape.js")}  <- only the new commit came in`);

  // The fix: revert the revert, which brings the original changes back.
  git("revert", "--no-edit", "HEAD^1"); // HEAD^1 = the revert commit on main
  console.log(`revert the revert: export.js ${has("export.js")}, escape.js ${has("escape.js")}`);
  console.log("\n" + git("log", "--graph", "--oneline"));
} finally {
  rmSync(dir, { recursive: true, force: true });
}
Result
after revert -m 1: export.js missing, escape.js missing
merge again:       export.js missing, escape.js present  <- only the new commit came in
revert the revert: export.js present, escape.js present

* f473f33 Reapply "Merge pull request #7 from feature/export"
*   ce6ed05 Merge pull request #9 from feature/export
|\  
| * 64e9c1c fix: escape commas in CSV export
* | 548f798 Revert "Merge pull request #7 from feature/export"
* | ea3438a Merge pull request #7 from feature/export
|\| 
| * 09947bf feat: CSV export
* | 3af1e20 feat: menu
|/  
* 513507a chore: initial commit
What’s happening
  1. PR #7 (feat: CSV export, which adds export.js) is merged with a merge commit. Production breaks, so it's reverted with git revert -m 1 HEAD. A merge commit has two parents; -m 1 says "parent 1 (main) is the mainline, undo what parent 2 (the branch) brought in". export.js is gone.
  2. The author fixes the bug on the same branch (fix: escape commas in CSV export, adding escape.js) and PR #9 merges it again. The merge succeeds, but export.js is still missing: the original feat: CSV export commit is already an ancestor of main (merge #7 brought it in), so Git considers it merged; only the new commit is applied. The revert is still in effect.
  3. The fix is to revert the revert: git revert HEAD^1, where HEAD^1 is the first parent of merge #9, the revert commit. Git 2.54 titles it Reapply "Merge pull request #7 from feature/export" (the revert-of-a-revert message was changed in Git 2.43; older versions write Revert "Revert …"), and both files are present.
  4. The graph tells the story. The alternatives teams use: reapply the original changes as a fresh branch (cherry-pick or rebase the commits so they get new hashes), or, with squash merging, simply revert the squash commit and later merge a new squash commit, which avoids the trap because no branch commits were ever on main.
AdvancedPreventing conflicts in the first place
  • Integrate often. Conflicts grow with the time branches spend apart; daily integration (see Trunk-based development vs GitFlow) keeps them small.
  • Run the formatter everywhere. Half of all "conflicts" in teams without one are whitespace and quote style. A shared Prettier config and a pre-commit hook remove them.
  • Split hot files. A giant routes.js or constants.ts that every feature edits will conflict constantly; split it by feature.
  • Turn on rerere (git config rerere.enabled true, "reuse recorded resolution") if you rebase long branches repeatedly: Git records how you resolved a conflict and replays the same resolution the next time the identical conflict appears.
  • Lock files: for package-lock.json, don't hand-merge; take either side and re-run npm install to regenerate it, then commit the result.
Revert or reset?

You merged a PR to main an hour ago with a merge commit; three other PRs have been merged on top since. The first PR turns out to break login. Which command undoes it, and why not the other?

Show answer

git revert -m 1 <merge-commit-of-the-PR>, pushed through a normal PR.

What’s happening
  1. main is shared and has moved on: three later merges sit on top. git reset can only move the branch back to a point in history, so resetting past the bad merge would also remove the three good PRs, and it would rewrite main for everyone.
  2. git revert adds one new commit that undoes exactly that PR's changes and leaves everything else alone. Because the target is a merge commit, -m 1 names the mainline parent so Git knows which side to undo.
  3. Pushing the revert through a PR means CI checks that the three later PRs still work without the reverted code (they might depend on it).
  4. When the PR author fixes login, remember the trap: re-merging the same branch won't bring the original commits back; revert the revert (or reapply the change on a fresh branch) as part of the new PR.

#Finding the commit that broke it with git bisect

Added

git bisect answers "which commit introduced this bug?" when you know a good version and a bad one and there are too many commits in between to read. It's a binary search over history: Git checks out the commit halfway between good and bad, you (or a script) say whether it's good or bad, and Git halves the range again. With git bisect run <command>, Git runs your test at each step on its own and stops at the first bad commit.

The mental model is the "guess my number between 1 and 100" game: each answer halves what's left, so a range of 1,000 commits takes about 10 tests (2^10 = 1,024), and 24 commits take about 5. The test can be anything that exits 0 for good and 1 for bad: a unit test, a curl against a local server, a script that times a request.

Start: v3.0.0 is good, HEAD (change 24) is bad. 24 suspects. Test 1: change 12 passes → good. Everything up to 12 is cleared. 12 Test 2: change 18 fails → bad. Everything after 18 is cleared. 18 Test 3: change 15 passes → good. 15 Test 4: commit 17 (refactor: simplify clamp) fails → bad. 17 Test 5: change 16 passes → good. 17 is the first bad commit. 16
Each test halves the suspects. The model below runs exactly these five tests.
bisect-run.mjs
// 24 commits, one of which broke volume clamping. git bisect run finds it by running a test
// at each step. Real git in a throwaway repository, fixed names and dates, Git 2.54.
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";

const dir = mkdtempSync(join(tmpdir(), "git-demo-"));
let minute = 0;
const git = (...args) => {
  const date = `${1767603600 + 60 * minute++} +0000`;
  return execFileSync("git", args, {
    cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
    env: { ...process.env, LC_ALL: "C", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1",
      PATH: `${dirname(process.execPath)}:${process.env.PATH}`, // so the test can run `node`
      GIT_AUTHOR_NAME: "Asha", GIT_AUTHOR_EMAIL: "asha@example.com", GIT_AUTHOR_DATE: date,
      GIT_COMMITTER_NAME: "Asha", GIT_COMMITTER_EMAIL: "asha@example.com", GIT_COMMITTER_DATE: date },
  }).trimEnd();
};
const write = (file, text) => writeFileSync(join(dir, file), text);
const good = "export const clamp = (v) => Math.min(100, Math.max(0, v));\n";
const bad = "export const clamp = (v) => Math.min(100, v); // lost the lower bound\n";

try {
  git("-c", "init.defaultBranch=main", "init", "-q");
  write("volume.mjs", good);
  // The test exits 0 when the code is good and 1 when it's bad: that's all bisect run needs.
  write("test-volume.mjs", 'import { clamp } from "./volume.mjs";\nprocess.exit(clamp(-5) === 0 ? 0 : 1);\n');
  git("add", ".");
  git("commit", "-q", "-m", "feat: volume control");
  git("tag", "v3.0.0"); // the last release, known good
  for (let i = 1; i <= 24; i++) {
    write(`change-${i}.txt`, `${i}\n`);
    if (i === 17) write("volume.mjs", bad); // the regression hides among unrelated changes
    git("add", ".");
    git("commit", "-q", "-m", i === 17 ? "refactor: simplify clamp" : `chore: change ${i}`);
  }

  console.log(git("bisect", "start", "HEAD", "v3.0.0"));
  const out = git("bisect", "run", "node", "test-volume.mjs");
  console.log(out);
  console.log("\ngit bisect log (the decisions, replayable with git bisect replay):");
  console.log(git("bisect", "log").split("\n").filter((l) => !l.startsWith("#")).join("\n"));
  git("bisect", "reset");
} finally {
  rmSync(dir, { recursive: true, force: true });
}
Result
Bisecting: 11 revisions left to test after this (roughly 4 steps)
[c00bddcf18e0b55473651a2da4ff625e22be0149] chore: change 12
running 'node' 'test-volume.mjs'
Bisecting: 5 revisions left to test after this (roughly 3 steps)
[cd94e553d15fa2ef09bb970d972c1c620222cdcb] chore: change 18
running 'node' 'test-volume.mjs'
Bisecting: 2 revisions left to test after this (roughly 2 steps)
[dba5ce244bd6d7a9d570f40a12a4fb3375d50f38] chore: change 15
running 'node' 'test-volume.mjs'
Bisecting: 0 revisions left to test after this (roughly 1 step)
[7e30f82100636e63875dfe9c05dae1da50b890a4] refactor: simplify clamp
running 'node' 'test-volume.mjs'
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[bc94d40a5241730d051e57b909e02a1a51091d2d] chore: change 16
running 'node' 'test-volume.mjs'
7e30f82100636e63875dfe9c05dae1da50b890a4 is the first bad commit
commit 7e30f82100636e63875dfe9c05dae1da50b890a4
Author: Asha <asha@example.com>
Date:   Mon Jan 5 09:37:00 2026 +0000

    refactor: simplify clamp

 change-17.txt | 1 +
 volume.mjs    | 2 +-
 2 files changed, 2 insertions(+), 1 deletion(-)
 create mode 100644 change-17.txt
bisect found first bad commit

git bisect log (the decisions, replayable with git bisect replay):
git bisect start 'HEAD' 'v3.0.0'
git bisect good c00bddcf18e0b55473651a2da4ff625e22be0149
git bisect bad cd94e553d15fa2ef09bb970d972c1c620222cdcb
git bisect good dba5ce244bd6d7a9d570f40a12a4fb3375d50f38
git bisect bad 7e30f82100636e63875dfe9c05dae1da50b890a4
git bisect good bc94d40a5241730d051e57b909e02a1a51091d2d
What’s happening
  1. The repository has v3.0.0, a known-good release where clamp(-5) returns 0, and 24 commits after it. Commit 17, refactor: simplify clamp, quietly drops the lower bound so clamp(-5) returns -5; the other 23 commits touch unrelated files. The test file exits 0 when clamp(-5) === 0 and 1 otherwise: that exit code is the whole contract with bisect.
  2. git bisect start HEAD v3.0.0 names the bad commit first and the good one second. There are 24 candidates for "first bad commit", so Git checks out the middle one, change 12, and prints Bisecting: 11 revisions left to test after this (roughly 4 steps).
  3. git bisect run node test-volume.mjs takes over. change 12 passes (good, so 1 to 12 are cleared); change 18 fails (bad, so 19 to 24 are cleared); change 15 passes; refactor: simplify clamp fails; change 16 passes. Five test runs for 24 commits, as the halving predicts.
  4. Git concludes 7e30f82… is the first bad commit and shows it with its stats: it touched volume.mjs, which is the smoking gun. Because the commit is small and has a clear message, the cause is obvious; a 2,000-line squash commit would only have narrowed it to "somewhere in this PR".
  5. git bisect log records every decision; you can save it, edit a wrong answer and replay it with git bisect replay. git bisect reset returns you to where you started. Note the hashes: everything is deterministic because the model fixes names and dates, so this exact log reproduces on every run.
AdvancedWhen some commits can't be tested

When some commits can't be tested: exit code 125

Real histories contain commits that don't build. The git bisect documentation (Git 2.54) defines the exit codes for bisect run: 0 means good, 1 to 127 except 125 means bad, 125 means "this commit can't be tested, skip it", and anything else aborts the bisect. This model has two commits where the module has a syntax error:

bisect-skip.mjs
// When some commits can't be tested (the build was broken), the test exits 125 and bisect skips them.
// Real git in a throwaway repository, fixed names and dates, Git 2.54.
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";

const dir = mkdtempSync(join(tmpdir(), "git-demo-"));
let minute = 0;
const git = (...args) => {
  const date = `${1767603600 + 60 * minute++} +0000`;
  return execFileSync("git", args, {
    cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
    env: { ...process.env, LC_ALL: "C", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1",
      PATH: `${dirname(process.execPath)}:${process.env.PATH}`,
      GIT_AUTHOR_NAME: "Asha", GIT_AUTHOR_EMAIL: "asha@example.com", GIT_AUTHOR_DATE: date,
      GIT_COMMITTER_NAME: "Asha", GIT_COMMITTER_EMAIL: "asha@example.com", GIT_COMMITTER_DATE: date },
  }).trimEnd();
};
const write = (file, text) => writeFileSync(join(dir, file), text);

// exit 125 = "can't test this one" (here: the module doesn't even load); 0 = good; 1 = bad.
const test = `let clamp;
try { ({ clamp } = await import("./volume.mjs")); } catch { process.exit(125); }
process.exit(clamp(-5) === 0 ? 0 : 1);
`;

try {
  git("-c", "init.defaultBranch=main", "init", "-q");
  write("volume.mjs", "export const clamp = (v) => Math.min(100, Math.max(0, v));\n");
  write("test-volume.mjs", test);
  git("add", ".");
  git("commit", "-q", "-m", "feat: volume control");
  for (let i = 1; i <= 8; i++) {
    if (i === 3) write("volume.mjs", "export const clamp = (v) => Math.min(100, Math.max(0, v)) +;\n"); // syntax error
    if (i === 4) write("volume.mjs", "export const clamp = (v) => Math.min(100, v) +;\n"); // bug, still broken
    if (i === 5) write("volume.mjs", "export const clamp = (v) => Math.min(100, v);\n"); // builds again, bug remains
    write("n.txt", `${i}\n`);
    git("add", ".");
    git("commit", "-q", "-m", `change ${i}`);
  }
  console.log(git("bisect", "start", "HEAD", "HEAD~8"));
  try {
    git("bisect", "run", "node", "test-volume.mjs");
  } catch (err) { // bisect run exits non-zero when it can't name a single commit
    const name = (hash) => git("log", "-1", "--format=%h %s", hash);
    const lines = err.stdout.trim().split("\n").filter((l) => !l.startsWith("running "));
    console.log(lines.map((l) => (/^[0-9a-f]{40}$/.test(l) ? `  ${name(l)}` : l)).join("\n"));
    console.log(`exit status ${err.status}: ${err.stderr.trim()}`);
  }
  git("bisect", "reset");
} finally {
  rmSync(dir, { recursive: true, force: true });
}
Result
Bisecting: 3 revisions left to test after this (roughly 2 steps)
[dd8b2d1e17ba43574027989de5d8ff04835f7137] change 4
Bisecting: 3 revisions left to test after this (roughly 2 steps)
[d09267ff79e6f51896301845774c0ea0e1fe041b] change 2
Bisecting: 2 revisions left to test after this (roughly 2 steps)
[c3954479db8818d61255f7236d1412c147abe4bd] change 5
Bisecting: 0 revisions left to test after this (roughly 1 step)
[ddea829ddc89364c221b414f9486b73a3cec725a] change 3
There are only 'skip'ped commits left to test.
The first bad commit could be any of:
  dd8b2d1 change 4
  ddea829 change 3
  c395447 change 5
We cannot bisect more!
exit status 2: error: bisect run cannot continue any more
What’s happening
  1. Eight commits after a good one. In change 3 the module gets a syntax error; in change 4 the real bug arrives (the lower bound is dropped) while it still doesn't load; change 5 fixes the syntax but keeps the bug.
  2. The test wraps the import in try/catch and exits 125 if the module can't even be loaded. That's the important design choice: "doesn't build" is not the same as "has the bug". If it exited 1, bisect would blame change 3, which broke the build, not the behaviour.
  3. Bisect starts at change 4 (skip), tries change 2 (good), then change 5 (bad), then change 3 (skip). The only candidates left are the bad one and the two it couldn't test.
  4. So it stops with There are only 'skip'ped commits left to test and lists all three as possible first bad commits; bisect run exits with status 2. You've gone from eight suspects to three, and you can read those three diffs by hand.
  5. The lesson for commit hygiene: every commit that doesn't build or pass tests widens bisect's answer. Teams that squash-merge don't hit this on main (each squash commit passed CI); teams that rebase-merge should make sure every commit in the PR builds.
AdvancedBisect tips
  • Keep the test script outside the repository (or in a path that doesn't change between commits). Bisect checks out old commits; a script inside the repo might not exist, or might be a different version, at the commit being tested. The model's test exists, unchanged, in every commit, which is why it could live in the repo.
  • Not a bug, a change in behaviour? Use your own terms: git bisect start --term-old fast --term-new slow for a performance regression, or --term-old broken --term-new fixed to find which commit fixed something.
  • Merge-heavy history: git bisect start --first-parent follows only the first parent of merge commits, so it tests one merged PR at a time instead of wandering into each branch's commits.
  • Limit to a path: git bisect start <bad> <good> -- src/api/ only considers commits that touched src/api/.
  • Manual mode works the same without a script: git bisect good or git bisect bad after you check by hand, git bisect skip when you can't tell.
How many steps?

main has 1,000 commits since the last good release. Roughly how many test runs does git bisect run need, and what would make the answer worse?

Show answer

About 10 test runs (log2(1000) ≈ 9.97).

What’s happening
  1. Each test halves the candidates: 1,000 → 500 → 250 → 125 → 63 → 32 → 16 → 8 → 4 → 2 → 1. That's 10 halvings, so about 10 runs; Git prints its own estimate ("roughly N steps") as it goes.
  2. Untestable commits make it worse: each skip costs a test run without halving anything, and if the bug sits next to skipped commits, bisect can only return a short list of candidates, as the exit-125 model showed.
  3. A flaky test makes it wrong, which is worse than slow: one false "good" sends the search into the wrong half and it confidently names an innocent commit. Make the check deterministic, or run it a few times inside the script and only report bad if it fails consistently.
  4. Huge commits don't change the number of steps but make the answer less useful: "the first bad commit is a 3,000-line merge" still leaves the real hunt to you.

Written fresh for a lead full stack role. Git examples were run; GitHub Actions workflows were checked with actionlint. Behavioural stories are yours to write in My notes.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.