The previous lesson ended with an uncomfortable exercise: a perfectly written BREAKING CHANGE disappeared when the branch was integrated with squash merge, and the versioning tool released as a MINOR a change that broke every consumer. The message was impeccable; what failed was the integration policy.

This lesson settles that question and, with it, the one we deliberately left open in lesson 07-04: when the moment comes to merge a pull request, merge, squash or rebase? It is probably the most recurrent and most religious argument in every team that uses Git, and it has a reasoned answer — even if it is not the same one for every project.

But first there is a prior question that almost nobody asks: clean for what? A history is not a work of art. It is a working tool with concrete, measurable uses. A "pretty" history that serves none of those uses is not a clean history: it is expensive decoration.

Contents

  1. What a history is really for
  2. The atomic commit
  3. How to get to atomic commits: add -p and rebase -i
  4. The integration decision: merge, squash or rebase
  5. An in-depth comparison table
  6. Which policy fits each workflow
  7. A reasoned recommendation by project type
  8. Clean up before publishing, never touch what is published
  9. Merge commits: when they contribute and when they are noise
  10. Linear history versus history with topology
  11. Mass formatting commits and .git-blame-ignore-revs
  12. What it means for a history to be "bisectable"

  1. What a history is really for

If you are never going to look back, any history will do. The quality of the history is only paid for when you consult it, and it is consulted for five very concrete things. You already know all of them from the course:

Use Tool What it needs from the history
Understanding the why of a line git blame + git show (06-03) Small commits with messages that explain the motivation
Locating where something broke git bisect (06-02) Every commit builds and passes the tests
Undoing a specific change git revert (05-06) That change isolated in its own commit
Reviewing a proposal git diff main...branch (07-02) Commits that tell a story step by step
Auditing what went in and when git log, git tag --contains (05-05, 06-04) Traceability of branch, author and ticket

Notice that none of the five asks for a pretty graph. They ask for commits that are small, self-contained, well described and working. That is the real goal. The topology — linear or with branches — is a means, not an end, and that is why the merge/squash/rebase argument can only be settled by asking which of these five uses matters most in your project.

Working definition: a clean history is one in which any commit can be understood, tested, reverted and attributed on its own.

  1. The atomic commit

An atomic commit is a commit that contains one complete change and only one. Both halves of the definition matter equally:

  • Complete: it does not leave the project broken. It builds, it passes the tests, the functionality it touches works.
  • Only one: it does not drag along changes unrelated to its purpose.

What goes in and what does not

Goes into the commit Does not go in
The code of the change Another, different change you made along the way
Its tests Reformatting of files you only opened to read
The documentation that the change invalidates Renaming variables "while I'm here"
The data migration that the change requires Fixing a typo in another module
The dependency change that the code requires A debugging console.log

The classic pathological case, which everybody has committed at some point:

git log --oneline -1 --stat
a1b2c3d Add the filter by label

 app.js            | 340 ++++++++++++++++++++++++-------------------
 styles.css        | 128 +++++++++++++----
 index.html        |  22 ++-
 README.md         |   4 +-
 package.json      |   6 +-
 .github/ci.yml    |  18 ++-
 6 files changed, 340 insertions(+), 178 deletions(-)

340 lines changed in app.js to "add a filter". Inside there are, almost certainly: the filter (30 lines), a reformat of the whole file done by the editor on save (250 lines), a rename of two variables (20), an unrelated typo fix (5) and a dependency bump (the package.json).

What you lose with that commit:

  • git blame on any line of app.js leads you to it, even if that line only changed its indentation.
  • If the filter has a bug, git revert forces you to revert the dependency bump as well.
  • git bisect will point at it as the culprit, but the diff is so large that you will have made no progress at all.
  • The reviewer cannot evaluate it: 340 lines of which 250 are noise.

The subject test

There is a very simple test, and it links back to the previous lesson: if you cannot describe the commit in a 50-character subject without using "and", it is not atomic. The inability to name it is the symptom.

  1. How to get to atomic commits: add -p and rebase -i

The practical objection is a real one: "but I don't program like that, I just go around touching things". Nobody programs in atomic commits. Atomic commits are manufactured afterwards, with two tools you already know well.

Before committing: git add -p

In lesson 02-04 we saw git add -p for staging changes hunk by hunk. That is exactly its purpose here: you have touched three things in app.js and you want three commits.

git add -p app.js
@@ -12,7 +12,7 @@ function renderList(tasks) {
-  const visible = tasks.filter(t => !t.done);
+  const visible = applyFilters(tasks);

(1/4) Stage this hunk [y,n,q,a,d,j,J,g,/,s,e,?]?

You answer y to the filter hunks and n to the rest, you commit, and you repeat. Remember the keys that pay off most:

Key Effect
y / n Stage / do not stage this hunk
s Split the hunk into smaller hunks
e Edit the hunk by hand (to split it line by line)
q Quit

And one indispensable check before committing, because staging by hunks makes it possible to create a commit that does not build:

git diff --staged        # what is going in
git stash push --keep-index   # set aside what is NOT going in
npm test                 # does the commit work on its own?
git commit
git stash pop            # get the rest back

That stash --keep-index from lesson 05-04 is the only honest way of verifying that a commit manufactured with add -p really is complete.

After committing: git rebase -i

If you have already committed and the result is a disaster, the interactive rebase of lesson 05-02 fixes it, as long as you have not published it (section 8).

git rebase -i main
pick a1b2c3d wip
pick b2c3d4e still on the filter
pick c3d4e5f fix the earlier thing
pick d4e5f6a now it works
pick e5f6a7b add filter tests

It becomes:

pick a1b2c3d wip
fixup b2c3d4e still on the filter
fixup c3d4e5f fix the earlier thing
fixup d4e5f6a now it works
pick e5f6a7b add filter tests

And with reword on the first one, the result is two clean commits: feat(filters): add the filter by label and test(filters): cover the filtering by multiple labels.

The three commands that solve 90 % of cases:

Command When
fixup The commit was a correction of the previous one; its message is superfluous
squash The same, but you want to keep and combine the messages
reword The change is fine, the message is not
edit You need to split a commit in two (with reset HEAD^ and add -p)

And the flow that makes all of this automatic, from lesson 05-02: git commit --fixup=<sha> while you work, and git rebase -i --autosquash main at the end.

  1. The integration decision: merge, squash or rebase

Here we are. Bruno's GT-134-timeout-sync branch has four commits, it is approved and it is going into main. There are three ways of doing it, and they produce three different histories.

We always start from this situation:

gitGraph
    commit id: "A"
    commit id: "B"
    branch GT-134
    commit id: "C1"
    commit id: "C2"
    commit id: "C3"
    checkout main
    commit id: "D"

main has moved on with D while Bruno was working, so no fast-forward is possible.

Option 1: merge commit (--no-ff)

git switch main
git merge --no-ff GT-134-timeout-sync
gitGraph
    commit id: "A"
    commit id: "B"
    branch GT-134
    commit id: "C1"
    commit id: "C2"
    commit id: "C3"
    checkout main
    commit id: "D"
    merge GT-134 id: "M"

Bruno's three commits go in as they are, with their original SHAs and dates, and a merge commit M with two parents is added, marking where and when the branch was integrated. It is what we saw in lesson 03-03.

Option 2: squash merge

git switch main
git merge --squash GT-134-timeout-sync
git commit          # a new message is written
gitGraph
    commit id: "A"
    commit id: "B"
    commit id: "D"
    commit id: "S (C1+C2+C3)" type: HIGHLIGHT

The three commits are fused into a single one, S, with a new message. main stays linear. The original branch still exists in Bruno's repository, but main holds no reference to it: Git does not know that S comes from C1, C2 and C3.

Option 3: rebase and merge

git switch GT-134-timeout-sync
git rebase main
git switch main
git merge --ff-only GT-134-timeout-sync
gitGraph
    commit id: "A"
    commit id: "B"
    commit id: "D"
    commit id: "C1'"
    commit id: "C2'"
    commit id: "C3'"

The three commits are rewritten on top of D (new SHAs, same content and same message) and main moves forward in a straight line. It is the rebase of lesson 05-01.

The key observation

All three produce exactly the same file tree. The final code is identical byte for byte. The only thing that changes is what information is recorded about how we got there. That is why the decision is not technical: it is a decision about what information you want to be able to recover a year from now.

  1. An in-depth comparison table

Dimension Merge commit (--no-ff) Squash Rebase
History of main With topology: branch bubbles Strictly linear Strictly linear
No. of commits in main per PR N + 1 (the branch's plus the merge) Exactly 1 N
Are the original commits kept? Yes, with their SHA No, they disappear No: same content, new SHAs
Traceability of the branch Maximum: the M commit says which branch, when and who merged Only whatever the squash message says None: the commits are left loose
Readability of git log --oneline Noisy if there are many wip commits Maximum: one line per change Depends on the branch's discipline
git bisect It can land on a broken intermediate commit Optimal: every step is a whole, tested change Good if every commit builds; bad if not
Granularity of bisect Fine (it reaches the exact commit) Coarse (it reaches the whole PR) Fine
git revert One revert -m 1 undoes the whole PR Trivial: one commit, one revert You have to revert N commits, in reverse order
git blame Points at the real commit, with its detailed message Points at the squash: a more generic message Points at the real commit
Attribution of authorship Correct and per commit A single author; the rest only if there is Co-authored-by Correct and per commit
Dates The original authorship dates are kept A single date, that of the merge Original author date, new commit date
Conflicts Resolved once, at the merge Resolved once Resolved commit by commit (it may repeat)
Risk of rewriting None None on main; the branch is left orphaned The branch is rewritten: it requires push --force-with-lease
Compatibility with the golden rule (05-01) Total Total Only if the branch belongs to one person or is coordinated
--first-parent Very useful: it gives you the list of PRs Irrelevant (there are no merges) Irrelevant
SemVer derivation (08-01) Reads every commit: reliable Reads only the squash: fragile Reads them all: reliable
Learning curve Low Minimal High (you have to understand rebase and force-push)

The three points where it is really decided

1. revert versus bisect. Squash wins on revert and on readability; merge and rebase win on bisect granularity and on blame. Ask yourself which you do more often: undo whole features, or hunt down the exact commit that broke something?

2. The real quality of your team's commits. If branches arrive with wip, wip2 and now it works, merge and rebase put that rubbish into main for ever. Squash absorbs it. Squash is the policy that protects you from indiscipline; merge and rebase are the ones that reward discipline when it exists.

3. The size of the pull requests. With 200-line PRs, a squash loses little information. With 2,000-line PRs, the squash produces monstrous commits that ruin blame and bisect at once. Squash and small PRs go together; it is consistent with what we said about PR size in lesson 07-02.

  1. Which policy fits each workflow

Each workflow from module 7 has a policy that suits it naturally:

Workflow (module 7) Natural policy Reason
Git Flow (07-03) Merge --no-ff always The value is in the topology: you have to be able to see what went into develop, what was promoted to release and what slipped in through a hotfix. Flattening destroys the very information that justifies the workflow.
GitHub Flow (07-04) Squash by default Short branches, one person, one feature. One commit per PR in main produces a readable history where every line is a deployable unit.
Trunk Based Development (07-05) Rebase or squash Branches live for hours. The goal is a strictly linear trunk, and with branches that small the difference between the two options is minimal.
External fork (07-01) Merge or squash With Diego, who has no write access, rebasing his branch is awkward: you cannot rewrite his fork. It is merged or squashed from the project's side.

One nuance about Git Flow that gets overlooked: in a repository with systematic --no-ff, the high-level reading is done with --first-parent (section 9), which hides the inside of each bubble and leaves only the sequence of integrations. Without that tool, a history with topology is effectively illegible, and a good part of the rejection of merging comes from there.

  1. A reasoned recommendation by project type

There is no universal answer, but there are defensible answers depending on the project:

Web application with continuous deployment, team of 3 to 15 people → squash. This is the case of task-manager. The PRs are small, each one corresponds to a GT-NNN ticket, and what gets done most is answering "what changed this week?" and "revert the thing that broke production". main ends up with one line per change, revert is trivial and bisect lands on the guilty PR, which is granular enough when the PRs are 200 lines. Non-negotiable condition: the squash title is written with the same care as a commit (lesson 08-01) and the relevant trailers are propagated, including Co-authored-by and BREAKING CHANGE.

Library or product with versions and support for several branches → merge --no-ff. Here you have to answer "which version did this go into?", "is this patch on the 2.x branch?" and "which release did it come out of?". The topology is the answer, and git log --first-parent, git tag --contains and git branch --contains depend on it. Besides, with cherry-pick between maintenance branches (lesson 05-03), having the original commits intact makes the work far easier.

Open source project with many outside contributors → merge or squash, depending on the quality of the contributions. The Git project and the Linux kernel use merges with topological history, but they require contributors to submit already-clean patch series. A project that cannot demand that is better off squashing.

Small internal repository, 1 or 2 people → whatever you find comfortable. With two people and shared context, the practical difference is small. Pick one and stop thinking about it.

The cross-cutting rule, the one that really matters:

Choose a policy, configure it on the platform so that it is the only one available, and write it down in the README.md. The worst of all worlds is a main where some people squashed, others merged and others rebased: it cannot be read consistently with any tool, --first-parent gives absurd results and nobody knows what to expect.

On task-manager, the written agreement ended up like this:

## Integration policy

All pull requests are integrated with **squash merge**.

- The PR title is the subject of the commit: `type(scope): description` (see 08-01).
- The PR description is the body of the commit: the why.
- The `Refs: GT-NNN`, `Co-authored-by` and `BREAKING CHANGE` trailers are
  copied into the squash box before confirming the merge.
- PRs are kept below 400 lines. If they grow, they are split.
- The branch is deleted after merging.

  1. Clean up before publishing, never touch what is published

Everything in section 3 — rebase -i, fixup, reword, splitting commits — has a very clear boundary, and it is the golden rule of lesson 05-01:

Rewrite freely whatever only exists on your machine. Never rewrite what others have already downloaded.

The reason, recalled in one sentence: rewriting creates new commits with different SHAs. Anyone who had the old ones is left with two divergent versions of the same story, and their next pull produces a tangle that has to be undone by hand.

flowchart LR
    A["Local work<br/>messy commits"] -->|"rebase -i, fixup,<br/>add -p, amend"| B["Clean series"]
    B -->|"push"| C["Published"]
    C -->|"revert, new commits"| D["Keeps moving forward"]
    C -.->|"FORBIDDEN<br/>rebase, amend, force"| B

The grey area: your own PR branch

There is an intermediate case that has to be resolved explicitly, because it generates endless arguments: your feature branch is already on origin and open as a PR, and you want to clean it up before merging.

It is legitimate, with conditions:

  • The branch is yours and nobody else has based work on it. If Carla has made a commit on top of it, it no longer is.
  • You always use git push --force-with-lease (lesson 04-05), never plain --force: if somebody has pushed something in the meantime, the operation is rejected instead of destroying it.
  • You give notice in the PR. Reviewers lose track of their comments when the SHAs change.
  • If there are already review comments, add new commits instead of rewriting, and squash at the end with the merge's squash. That is the main reason squash is so popular: it makes the clean-up rebase unnecessary.
  • To review what changed between the previous version and the rewritten one, git range-diff from lesson 07-02.

Never, under any circumstances: rewrite main, a release branch, or a shared branch. If something has to be undone there, it is undone with git revert (lesson 05-06), which adds a new commit instead of deleting the old one.

  1. Merge commits: when they contribute and when they are noise

Not all merge commits are the same. There are two kinds and it is worth telling them apart.

Merges that contribute

They are the deliberate ones: git merge --no-ff of a feature branch into main. The commit records a decision — "this feature was integrated here" — and its message can document it:

Merge branch 'GT-134-timeout-sync'

Raises the sync timeout after the measurements taken in
pre-production during the week of the 12th. Approved by Ana.

Refs: GT-134

That commit is pure information, and with --first-parent it produces an excellent high-level history.

Merges that are noise

They are the accidental ones: those that appear when you run git pull and the remote has moved on.

Merge branch 'main' of git.example.com:team/task-manager

That commit records no decision. It records that Carla ran pull on a Tuesday at 11:40. A main with two hundred of these is illegible, and they fill the graph with bubbles that mean nothing.

The cure is the one from lesson 04-04, and it should be in everybody's global configuration:

git config --global pull.rebase true

Or, if you prefer Git to force you to decide instead of choosing for you:

git config --global pull.ff only
# If no fast-forward is possible, git pull fails and you decide

Reading a history with many merges: --first-parent

A merge commit has two parents: the first is the branch you were on (main) and the second is the one you merged. --first-parent follows only the first, which means it hides the internal content of each branch and leaves only the sequence of integrations.

# Everything, including the internal commits of each branch
git log --oneline
9f8e7d6 Merge branch 'GT-141-indexeddb'
6c5b4a3 test(sync): cover the migration from localStorage
3a2b1c0 feat(sync): store the tasks in IndexedDB
8d7c6b5 refactor(sync): extract the data access into a module
7f6e5d4 Merge branch 'GT-134-timeout-sync'
4e3d2c1 fix(sync): avoid duplicating tasks when retrying
1b0a9f8 chore(sync): raise the timeout to 30 s
...
# Only the line of integrations
git log --oneline --first-parent
9f8e7d6 Merge branch 'GT-141-indexeddb'
7f6e5d4 Merge branch 'GT-134-timeout-sync'
...

From hundreds of lines to a dozen. This is the argument that rescues the merge: a history with topology is not illegible, it is a history with two levels of reading. The high-level one is obtained with --first-parent, and the detail is still there when you need it.

It deserves an alias, picking up on those of lesson 06-04:

git config --global alias.integrations \
  "log --oneline --first-parent --decorate"

--first-parent also works in other commands:

git log --first-parent --stat        # which files each PR touched
git bisect start --first-parent      # bisect by PR, not by internal commit
git blame --first-parent file        # attribute to the merge, not to the internal commit

That git bisect --first-parent is especially useful: it turns a history with merges into something with the same granularity as a squash history, but without losing the detail when you want to drill down.

  1. Linear history versus history with topology

flowchart TB
    subgraph LIN["Linear (squash or rebase)"]
        direction LR
        L1["A"] --> L2["B"] --> L3["C"] --> L4["D"] --> L5["E"]
    end
    subgraph TOP["With topology (merge --no-ff)"]
        direction LR
        T1["A"] --> T2["B"] --> TM1["M1"] --> TM2["M2"]
        T2 --> R1["C1"] --> R2["C2"] --> TM1
        TM1 --> S1["D1"] --> S2["D2"] --> TM2
    end
Linear With topology
Reading git log --oneline Direct, no tools needed Needs --first-parent for the high level
git log --graph One column Several columns; with many parallel branches, illegible
Context of each change The grouping by branch is lost Explicit: you can see which commits went together
When the work was done The order is that of integration, not the real one You see the concurrent work exactly as it happened
bisect Direct, no surprises It can go inside a branch; --first-parent avoids that
revert of a PR Squash: one commit. Rebase: N commits revert -m 1 on the merge, just one
Historical honesty It is a reconstruction: the order never happened that way It is a record: it reflects what really happened
Maintenance cost Requires rebase and force-push (rebase) or losing detail (squash) None: it is what comes out by itself

The last row is the underlying tension, and it deserves saying plainly: a linear history is a useful fiction. Nobody worked in that order. Ana and Bruno programmed at the same time for three days; the linear history tells you that one finished and then the other started. The honest question is not "which is truer?" but "is the concurrent truth of any use to me?". For most products, no. For a project supporting several versions in parallel, yes, and very much so.

  1. Mass formatting commits and .git-blame-ignore-revs

There is a type of commit that breaks the history more than any other, and it is not a bad commit: it is necessary. The day the task-manager team decided to adopt Prettier, Ana ran the formatter over the whole project:

npx prettier --write .
git commit -am "style: apply Prettier to the whole project"
 app.js       | 1204 ++++++++++++++++++++--------------------
 styles.css   |  486 +++++++--------
 index.html   |  152 ++---
 3 files changed, 921 insertions(+), 921 deletions(-)

The damage: from that moment on, git blame app.js attributes every line of the file to Ana and to that commit. All the authorship and intent information of the last two years is buried under a commit that did not change a single comma of behaviour.

The rules to make it hurt as little as possible:

  1. Isolate the formatting in its own commit. Never mixed with functional changes: it is the single most important application of the atomic commit principle.
  2. Make sure that commit does not change behaviour. Only the formatter; nothing by hand. It must be reproducible by running the tool.
  3. Say so in the message, including the version of the tool and its configuration, so that it is reproducible.
  4. Record it in .git-blame-ignore-revs.

That file, which we saw in lesson 06-03 and which we are now closing off, is a list of SHAs that git blame must see through as though they did not exist:

# .git-blame-ignore-revs
# Mass formatting commits that blame should ignore.
# Documentation: git blame --ignore-revs-file
#
# Adoption of Prettier 3.2 across the whole project (GT-160)
a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
#
# Migration from tabs to 2 spaces in styles.css (GT-171)
b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1
#
# Alphabetical reordering of the CSS properties (GT-183)
c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2

Requirements: full 40-character SHAs (abbreviated ones do not work) and one line per commit.

It is used like this:

# One-off
git blame --ignore-revs-file .git-blame-ignore-revs app.js

# Permanently, for this repository
git config --local blame.ignoreRevsFile .git-blame-ignore-revs

With that local configuration, git blame app.js shows the real authors of each line again. The most common hosting platforms recognise the file by its conventional name and apply it automatically in their blame view.

The detail you have to document in the README.md: blame.ignoreRevsFile is local configuration (lesson 01-05), so it does not travel with the clone. The file is indeed versioned, but each person has to activate it. Add it to the project's set-up script:

# repository bootstrap script
git config --local blame.ignoreRevsFile .git-blame-ignore-revs
git config --local commit.template .gitmessage
git config --local core.hooksPath .githooks

  1. What it means for a history to be "bisectable"

In lesson 06-02 we said that git bisect is only useful on a "bisectable" history and we pointed here. We now have all the pieces to define it.

A history is bisectable when any commit picked at random can be built, started and tested.

git bisect works by binary search: it drops you on an intermediate commit and asks you whether it is good or bad. If that commit does not even build, you cannot answer either of those things. You have to mark it git bisect skip, and every skip degrades the search. With enough broken commits, the bisection stops converging and is of no use at all.

What breaks bisectability

Practice Effect on bisect
wip commits that do not build Each one is a forced skip
Separating the code from its tests into two commits The intermediate commit "fails" for a false reason
Adding a dependency in one commit and using it in the previous one The intermediate commit does not start
Changing the data schema without its migration It starts but fails at run time
Giant PRs squashed It always builds, but bisect only reaches "this 2,000-line PR"
add -p without verifying the result Commits that do not include everything they need

What guarantees it

  1. Atomic, complete commits: section 2. Each one leaves the project working.
  2. The code and its tests, in the same commit.
  3. CI on every commit, not just on the tip of the branch. It is the only real check. With the validation from lesson 07-06, the build can be run on every commit of the PR:
      - name: Check that every commit builds
        run: |
          BASE="${{ github.event.pull_request.base.sha }}"
          for sha in $(git rev-list --reverse "$BASE..HEAD" --no-merges); do
            echo "::group::Building $sha"
            git checkout --quiet "$sha"
            npm ci --silent && npm run build --silent || {
              echo "::error::Commit $sha does not build: it breaks bisectability"
              exit 1
            }
            echo "::endgroup::"
          done
  1. git rebase --exec, from lesson 05-02, so you can check it yourself before publishing. It is the perfect tool for this:
# Runs the tests on every commit since main; it stops at the first one that fails
git rebase main --exec "npm test"

If the rebase stops, you have a non-bisectable commit on the branch. You fix it with edit or fixup and carry on.

And the integration policy

It closes the circle of section 4:

  • Squash guarantees bisectability almost for free: every commit on main is a whole PR, which passed CI. The granularity is coarse, but it never fails.
  • Merge with --first-parent gives you the same as squash, and lets you drill down to the detail when the PR is large.
  • Rebase gives the finest granularity, but only if every commit builds, which demands discipline and a check like the one above.

Common Mistakes and Tips

Mistake 1: confusing "clean" with "pretty". A graph with a single straight line that does not let you revert, bisect or understand anything is not clean. The five uses in section 1 are the criterion; aesthetics is not.

Mistake 2: mixing integration policies in the same repository. It is worse than choosing the wrong policy. Configure it on the platform leaving a single option active.

Mistake 3: squashing enormous PRs. A 2,000-line squash destroys blame and leaves bisect with no granularity. Squash demands small PRs; if you do not have them, fix that first.

Mistake 4: losing the BREAKING CHANGE in the squash. It is what we saw in exercise 3 of lesson 08-01. The squash message box comes pre-filled with the concatenation of all the commits; read it and edit it, do not accept it as it is nor empty it out.

Mistake 5: rebasing a shared branch. The golden rule of 05-01 has no practical exceptions. If you are in any doubt about whether somebody else has it, do not rebase it.

Mistake 6: accumulating pull merges. git config --global pull.rebase true and they disappear. It is a one-line change that improves the history for life.

Mistake 7: doing the mass formatting mixed with a functional change. You will bury the functional change under 900 lines of noise and ruin the blame with no chance of rescue, because .git-blame-ignore-revs cannot ignore a commit that also contains real code.

Mistake 8: thinking --first-parent does not exist. Many people reject merging as "illegible" without ever having tried git log --first-parent. Try it before deciding.

Tip 1: write the policy in the README.md. With the conditions (PR size, title format, trailers). Diego, who comes from outside, needs it before his first PR.

Tip 2: git rebase main --exec "npm test" before opening the PR. It is the cheapest way of guaranteeing that your branch is bisectable.

Tip 3: an alias for reading. git config --global alias.integrations "log --oneline --first-parent --decorate".

Tip 4: review your branch before publishing it. git log --oneline main..HEAD. If you see a wip, you still have work to do.

Tip 5: .git-blame-ignore-revs from the very first commit. Create it empty, with the commented header. That way, on the day of the first mass reformat, the place already exists.

Tip 6: delete merged branches. With squash, the branch is not linked from main; leaving it alive suggests there is work pending. Remember git branch --merged and git fetch --prune from lesson 03-06.

Exercises

Exercise 1: the three histories, hands on

Set up a test repository that reproduces the situation in section 4: main with two commits, a branch with three commits and a later commit on main. Then, from three copies of the same state, integrate the branch with each of the three policies.

For each result, answer:

  1. How many commits does main have?
  2. What does git log --oneline --graph show?
  3. What does git log --oneline --first-parent show?
  4. Which exact command would you use to undo the whole integration?
  5. If bisect pointed at the problem, what level of detail would it leave you at?

Exercise 2: manufacturing atomic commits out of a disaster

Simulate the pathological commit from section 2:

  1. Create an app.js with three functions and commit it.
  2. In a single change, modify one function (a functional change), reindent another (formatting) and fix a typo in a comment on the third.
  3. Use git add -p (with s and e where needed) to separate the three into three atomic commits with Conventional Commits messages.
  4. Verify with git stash push --keep-index that the first commit works on its own.
  5. Check with git log --oneline --stat that each commit touches only what it should.

Exercise 3: rescuing a ruined blame

  1. Create a repository with a styles.css of about 20 lines, written in three commits by three different authors (use git -c user.name=... -c user.email=... commit).
  2. Check with git blame styles.css that the authorship is correct.
  3. Apply a mass reformat (for example, change all the indentation) and commit it as style: reindent styles.css to 2 spaces.
  4. Check the damage with git blame.
  5. Create .git-blame-ignore-revs, configure blame.ignoreRevsFile and show that the original authorship is recovered.
  6. Explain why step 5 would have been impossible if the reformat had come mixed with a functional change.

Solutions

Solution 1:

# Starting state, reusable
mkdir /tmp/practice-hist && cd /tmp/practice-hist
git init -b main
git config user.name "Ana Ferrer"; git config user.email "ana.ferrer@example.com"

echo "line A" > app.js && git add . && git commit -m "chore: initial commit (A)"
echo "line B" >> app.js && git commit -am "feat: project base (B)"

git switch -c GT-134
echo "C1" >> app.js && git commit -am "chore(sync): raise the timeout (C1)"
echo "C2" >> app.js && git commit -am "fix(sync): avoid duplicates on retry (C2)"
echo "C3" >> app.js && git commit -am "test(sync): cover the retry (C3)"

git switch main
echo "D" >> README.md && git add . && git commit -m "docs: add the README (D)"

# Three copies of the same state
cd /tmp && cp -r practice-hist h-merge && cp -r practice-hist h-squash && cp -r practice-hist h-rebase
# --- MERGE ---
cd /tmp/h-merge
git merge --no-ff GT-134 -m "Merge branch 'GT-134'"
git log --oneline --graph
*   f1e2d3c Merge branch 'GT-134'
|\
| * c3d4e5f test(sync): cover the retry (C3)
| * b2c3d4e fix(sync): avoid duplicates on retry (C2)
| * a1b2c3d chore(sync): raise the timeout (C1)
* | 9a8b7c6 docs: add the README (D)
|/
* 8f7e6d5 feat: project base (B)
* 7e6d5c4 chore: initial commit (A)
# --- SQUASH ---
cd /tmp/h-squash
git merge --squash GT-134
git commit -m "fix(sync): avoid duplicating tasks when retrying the upload" \
           -m "Raises the timeout to 30 s and adds coverage of the retry." \
           -m "Refs: GT-134"
git log --oneline
5d4c3b2 fix(sync): avoid duplicating tasks when retrying the upload
9a8b7c6 docs: add the README (D)
8f7e6d5 feat: project base (B)
7e6d5c4 chore: initial commit (A)
# --- REBASE ---
cd /tmp/h-rebase
git switch GT-134 && git rebase main
git switch main && git merge --ff-only GT-134
git log --oneline
3c2b1a0 test(sync): cover the retry (C3)
2b1a0f9 fix(sync): avoid duplicates on retry (C2)
1a0f9e8 chore(sync): raise the timeout (C1)
9a8b7c6 docs: add the README (D)
8f7e6d5 feat: project base (B)
7e6d5c4 chore: initial commit (A)

The answers:

Merge Squash Rebase
1. Commits in main 6 (3+1 new) 4 (1 new) 6 (3 new)
2. --graph A visible fork with two branches One column One column
3. --first-parent 3 lines: A, B, D, M The same as --oneline The same as --oneline
4. Undoing git revert -m 1 f1e2d3c git revert 5d4c3b2 git revert 3c2b1a0 2b1a0f9 1a0f9e8 (or git revert 9a8b7c6..HEAD)
5. bisect detail The exact commit (or the PR, with --first-parent) The whole PR The exact commit

Look at row 4: with rebase, undoing the integration requires reverting three commits in reverse order, and if the middle one depends on the first, there are conflicts. It is the most practical disadvantage of pure rebase.

Solution 2:

mkdir /tmp/practice-atom && cd /tmp/practice-atom && git init -b main

cat > app.js <<'EOF'
function greet(n) { return "Hello " + n; }
function sum(a, b) { return a + b; }
// Returns the nubmer of tasks    <-- typo
function count(l) { return l.length; }
EOF
git add . && git commit -m "chore: initial state"
# 2. The three changes at once
cat > app.js <<'EOF'
function greet(n) { return `Hello ${n}`; }
function sum(a, b) {
  return a + b;
}
// Returns the number of tasks
function count(l) { return l.length; }
EOF
# 3. Separating them with add -p
git add -p app.js
# Hunk 1 (greet):     y
# Hunk 2 (sum):       n
# Hunk 3 (comment):   n
git commit -m "refactor(app): use template literals in greet()"

git add -p app.js
# The sum hunk: y ; the comment one: n
git commit -m "style(app): reformat sum() across several lines"

git add app.js
git commit -m "docs(app): fix the typo in the comment of count()"

If the changes were in the same hunk, s splits it and e lets you edit the hunk by hand, leaving a - on the lines you do not want to stage.

# 4. Verifying the isolation of the first commit
git reset --soft HEAD~2       # leaves the last two as staged changes
git stash push --keep-index    # sets aside what is not staged... (see note)
node -e "require('./app.js')"  # or npm test
git stash pop

In practice this is done before committing: you stage the hunk, git stash push --keep-index sets everything else aside, you run the tests on exactly what is going in, and only then do you commit and run git stash pop.

# 5. Each commit touches only its own thing
git log --oneline --stat -3

Solution 3:

mkdir /tmp/practice-blame && cd /tmp/practice-blame && git init -b main

printf '.task {\n    color: #333;\n}\n' > styles.css
git -c user.name="Ana Ferrer" -c user.email="ana.ferrer@example.com" \
    commit -am "feat(css): base style for the task" --allow-empty-message 2>/dev/null || {
  git add . && git -c user.name="Ana Ferrer" -c user.email="ana.ferrer@example.com" \
    commit -m "feat(css): base style for the task"
}

printf '.task.done {\n    opacity: 0.5;\n}\n' >> styles.css
git add . && git -c user.name="Bruno Salas" -c user.email="bruno@example.com" \
    commit -m "feat(css): dim the completed tasks"

printf '.task.overdue {\n    border-left: 3px solid #c00;\n}\n' >> styles.css
git add . && git -c user.name="Carla Vidal" -c user.email="carla@example.com" \
    commit -m "feat(css): mark the overdue tasks"
# 2. Correct authorship
git blame styles.css
^a1b2c3d (Ana Ferrer   2026-07-20 .task {
^a1b2c3d (Ana Ferrer   2026-07-20     color: #333;
b2c3d4e5 (Bruno Salas  2026-07-21 .task.done {
c3d4e5f6 (Carla Vidal  2026-07-22 .task.overdue {
# 3. The mass reformat
sed -i 's/^    /  /' styles.css
git -c user.name="Ana Ferrer" -c user.email="ana.ferrer@example.com" \
    commit -am "style: reindent styles.css to 2 spaces"

# 4. The damage
git blame styles.css

Now all the indented lines appear under Ana's name and under the style commit.

# 5. The rescue
SHA=$(git rev-parse HEAD)         # the full 40-character SHA, indispensable
cat > .git-blame-ignore-revs <<EOF
# Mass formatting commits that blame should ignore.
# Reindentation of styles.css to 2 spaces
$SHA
EOF
git add .git-blame-ignore-revs
git commit -m "chore: record the reindent commit in blame-ignore-revs"

git config --local blame.ignoreRevsFile .git-blame-ignore-revs
git blame styles.css       # original authorship recovered

6. Because .git-blame-ignore-revs works by skipping the whole commit: when blame finds that SHA, it attributes the line to the previous commit that touched it. If the commit also contained a real functional change, ignoring it would attribute that functional change to somebody who did not make it, and you would lose the information about who really introduced the code. The file is only safe with commits that demonstrably do not change behaviour. That is why rule 1 of section 11 — isolating the formatting — is not aesthetic advice: it is the condition that makes the rescue possible.

Conclusion

The essentials of this lesson:

  • A history is clean when any commit can be understood, tested, reverted and attributed on its own. The five real uses — blame, bisect, revert, review and audit — are the criterion; the aesthetics of the graph are not.
  • The atomic commit — one complete change and only one — is the unit that makes everything else possible. You do not program that way: you manufacture it afterwards, with git add -p before committing and with git rebase -i afterwards.
  • The decision we left open in 07-04 is settled like this:
    • Merge --no-ff when the topology is information: Git Flow, libraries with several live versions, projects with maintenance branches. It is read with --first-parent.
    • Squash when what matters is that main be a readable list of deployable changes: GitHub Flow, web applications, teams whose branches arrive with wip. It demands small PRs and a carefully written squash title.
    • Rebase when you want fine granularity and linearity at the same time, and the team has the discipline to make every commit build: Trunk Based Development.
    • And above all three: choose one, configure it as the only option on the platform and write it down in the README.md. Mixing them is worse than choosing badly.
  • You clean up before publishing; you do not touch what is published. The golden rule of 05-01 holds, with the single grey area of your own PR branch, and always with --force-with-lease and prior notice.
  • Deliberate merge commits are information; the accidental ones from git pull are noise that is eliminated with pull.rebase true. And --first-parent turns a history with topology into a history with two levels of reading, which rescues the merge from the charge of being illegible.
  • A mass reformat is isolated in its own commit, documented and recorded in .git-blame-ignore-revs with the full SHA, activating it with blame.ignoreRevsFile. Isolating it is not aesthetics: it is what makes the rescue possible.
  • A history is bisectable when any commit builds and can be tested. It is guaranteed with atomic commits, with the code and its tests together, with git rebase --exec before publishing and with CI on every commit.

task-manager now has messages that explain the why and a history that can be read, bisected and reverted. But it still has things inside it that should not be there: the node_modules folder that Bruno pushed without noticing, the .DS_Store files from macOS, the Thumbs.db files from Windows and a configuration file with a password in it.

The next thing is to decide what never goes into a repository, in lesson 08-03: Ignoring Files with .gitignore.

Mastering Git: From Beginner to Advanced

Module 1: Introduction to Git

Module 2: Basic Git Operations

Module 3: Branching and Merging

Module 4: Working with Remote Repositories

Module 5: Advanced Git Operations

Module 6: Git Tools and Techniques

Module 7: Collaboration and Workflow Strategies

Module 8: Git Best Practices and Tips

Module 9: Troubleshooting and Debugging

Module 10: Git in the Real World

© Copyright 2026. All rights reserved