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
- What a history is really for
- The atomic commit
- How to get to atomic commits:
add -pandrebase -i - The integration decision: merge, squash or rebase
- An in-depth comparison table
- Which policy fits each workflow
- A reasoned recommendation by project type
- Clean up before publishing, never touch what is published
- Merge commits: when they contribute and when they are noise
- Linear history versus history with topology
- Mass formatting commits and
.git-blame-ignore-revs - What it means for a history to be "bisectable"
- 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.
- 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:
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 blameon any line ofapp.jsleads you to it, even if that line only changed its indentation.- If the filter has a bug,
git revertforces you to revert the dependency bump as well. git bisectwill 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.
- How to get to atomic commits:
add -p and rebase -i
add -p and rebase -iThe 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.
@@ -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 backThat 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).
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.
- 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)
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
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-syncgitGraph
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.
- 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.
- 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.
- 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 amainwhere some people squashed, others merged and others rebased: it cannot be read consistently with any tool,--first-parentgives 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.
- 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-difffrom 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.
- 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-134That 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.
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:
Or, if you prefer Git to force you to decide instead of choosing for you:
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.
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 ...
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:
--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 commitThat 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.
- 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.
- Mass formatting commits and
.git-blame-ignore-revs
.git-blame-ignore-revsThere 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:
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:
- Isolate the formatting in its own commit. Never mixed with functional changes: it is the single most important application of the atomic commit principle.
- Make sure that commit does not change behaviour. Only the formatter; nothing by hand. It must be reproducible by running the tool.
- Say so in the message, including the version of the tool and its configuration, so that it is reproducible.
- 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)
c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2Requirements: 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-revsWith 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
- 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
- Atomic, complete commits: section 2. Each one leaves the project working.
- The code and its tests, in the same commit.
- 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::"
donegit 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
mainis a whole PR, which passed CI. The granularity is coarse, but it never fails. - Merge with
--first-parentgives 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:
- How many commits does
mainhave? - What does
git log --oneline --graphshow? - What does
git log --oneline --first-parentshow? - Which exact command would you use to undo the whole integration?
- If
bisectpointed 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:
- Create an
app.jswith three functions and commit it. - In a single change, modify one function (a functional change), reindent another (formatting) and fix a typo in a comment on the third.
- Use
git add -p(withsandewhere needed) to separate the three into three atomic commits with Conventional Commits messages. - Verify with
git stash push --keep-indexthat the first commit works on its own. - Check with
git log --oneline --statthat each commit touches only what it should.
Exercise 3: rescuing a ruined blame
- Create a repository with a
styles.cssof about 20 lines, written in three commits by three different authors (usegit -c user.name=... -c user.email=... commit). - Check with
git blame styles.cssthat the authorship is correct. - Apply a mass reformat (for example, change all the indentation) and commit it as
style: reindent styles.css to 2 spaces. - Check the damage with
git blame. - Create
.git-blame-ignore-revs, configureblame.ignoreRevsFileand show that the original authorship is recovered. - 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 "[email protected]"
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 --oneline5d4c3b2 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 --oneline3c2b1a0 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 popIn 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.
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="[email protected]" \
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="[email protected]" \
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="[email protected]" \
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="[email protected]" \
commit -m "feat(css): mark the overdue tasks"^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="[email protected]" \
commit -am "style: reindent styles.css to 2 spaces"
# 4. The damage
git blame styles.cssNow 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 recovered6. 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 -pbefore committing and withgit rebase -iafterwards. - The decision we left open in 07-04 is settled like this:
- Merge
--no-ffwhen 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
mainbe a readable list of deployable changes: GitHub Flow, web applications, teams whose branches arrive withwip. 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.
- Merge
- 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-leaseand prior notice. - Deliberate merge commits are information; the accidental ones from
git pullare noise that is eliminated withpull.rebase true. And--first-parentturns 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-revswith the full SHA, activating it withblame.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 --execbefore 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
- What Is Git?
- Installing Git
- Basic Git Terminology
- The Git Data Model
- Configuring Git
- Initial Configuration
Module 2: Basic Git Operations
- Creating a Repository
- Cloning a Repository
- The Basic Git Workflow
- Staging and Committing Changes
- Inspecting Changes with git diff
- Viewing Commit History
Module 3: Branching and Merging
- Understanding Branches
- Creating and Switching Branches
- Merging Branches
- Merge Strategies
- Resolving Merge Conflicts
- Branch Management
Module 4: Working with Remote Repositories
- Understanding Remote Repositories
- Adding a Remote Repository
- Authenticating with Remote Repositories
- Fetching and Pulling Changes
- Pushing Changes
- Tracking Branches
Module 5: Advanced Git Operations
Module 6: Git Tools and Techniques
- Using Git Hooks
- Git Bisect
- Git Blame
- Git Log and Aliases
- Git Submodules
- Multiple Working Copies with git worktree
Module 7: Collaboration and Workflow Strategies
- Forks and Pull Requests
- Code Reviews with Git
- The Git Flow Workflow
- GitHub Flow
- Trunk Based Development
- Continuous Integration with Git
Module 8: Git Best Practices and Tips
- Writing Good Commit Messages
- Keeping a Clean History
- Ignoring Files with .gitignore
- File Attributes with .gitattributes
- Security Best Practices
- Performance Tips
Module 9: Troubleshooting and Debugging
- Common Git Problems
- Undoing Changes
- Resolving Divergence with the Remote
- Recovering Lost Commits
- Dealing with Corrupted Repositories
- Advanced Debugging Techniques
