The previous lesson ended with the note Vincent Driessen himself added to his article ten years later: for continuous web development, Git Flow is too much. This lesson explains what lies on the other side of that recommendation.

GitHub Flow is the minimalist model. It grew out of GitHub's internal practice and was formalised by Scott Chacon in 2011 in an article titled, without ceremony, "GitHub Flow", with a premise that was almost a provocation at the time: for many projects, a single long-lived branch is enough.

And it is worth understanding where that claim comes from. It is not simplifying for the sake of convenience. It is that Git Flow solves a problem — coordinating several released versions and a stabilisation phase — that many projects do not have. If your application is deployed from a server you control, if there is only ever one version in production at a time, and if you can release a fix twenty minutes after writing it, then develop, release/* and hotfix/* are solving nothing for you: they are charging you complexity in exchange for nothing.

In task-manager the two realities coexist. The company sells the installable version — and for that one they keep Git Flow, as we saw — but it also runs a cloud version, app.example.com, which they deploy themselves several times a week. For that second line, the team has decided to try GitHub Flow.

Contents

  1. The idea in one sentence
  2. The six rules
  3. The complete cycle, with a graph
  4. The constraint that holds everything up: main always deployable
  5. What it demands in return
  6. The merge method: merge commit, squash or rebase
  7. Versions without release branches: tags on main
  8. Protected branches: the mechanism that enforces the flow
  9. When GitHub Flow fits
  10. Its limits

  1. The idea in one sentence

There is a single long-lived branch, main, which is always in a deployable state. Everything else is short branches that come off main, get reviewed in a pull request and go back into main.

That is the entire model. There is no develop, no release/*, no hotfix/*. An urgent fix does not need a class of branch of its own because every branch is equally fast: it comes off main, gets reviewed, goes in and gets deployed. The distinction between "urgent fix" and "ordinary feature" disappears from the branching model and becomes, at most, a label on the ticket.

The comparison of topologies, without yet going into the full comparison we shall make in lesson 07-05:

Git Flow GitHub Flow
Permanent branches 2 (main, develop) 1 (main)
Classes of supporting branch 3 (feature, release, hotfix) 1 (a working branch)
Rules of origin and destination 5 different combinations 1: from main to main
Double integrations Yes (release, hotfix) None

And a consequence you notice from day one: it is impossible to forget the second merge, because it does not exist.

  1. The six rules

Chacon stated them like this. We reproduce them with what each one means in practice.

Rule 1: anything in main is deployable

Not "it compiles". Not "it's nearly there". Deployable: if somebody deploys main to production right now, the result is correct. It is the rule the other five depend on, and we devote the whole of section 4 to it.

Rule 2: create descriptively named branches off main

git switch main
git pull
git switch -c feature/filter-by-label

Always from main, always up to date. The name should explain the work to somebody who only sees the list of branches: follow the conventions of lesson 03-06 — feature/, fix/, docs/ — and avoid tests, temp or anas-branch.

Rule 3: push to the named branch constantly

git commit -am "Filter the list by the selected label"
git push -u origin feature/filter-by-label

Not once at the end: often. Three reasons, and all three are practical:

  • Backup. If Bruno's laptop dies this afternoon, the work is on the server.
  • Visibility. The rest of the team sees what is being worked on and does not duplicate effort.
  • CI runs on every push (lesson 07-06). A failure found after ten minutes costs minutes; the same failure found three days later costs hours of rebuilding the context.

Rule 4: open a pull request at any time

At any time, including the first day and the first commit. This rule baffles a lot of people: why open a PR for something unfinished?

Because the PR is not just a request to merge: it is where the conversation lives (lesson 07-01). Opening it early lets you ask for an opinion on the approach before investing three days, leave a record of what you are on, have CI test it, and let somebody tell you "Carla already did that somewhere else" before it is too late. That is what draft PRs are for.

Rule 5: merge only after review

Nothing goes into main without another person having looked at it (lesson 07-02). And without CI being green. These two requirements do not rely on goodwill: they are imposed technically with protected branches (section 8).

Rule 6: deploy immediately after merging

Merging into main and deploying are, ideally, the same act. The shorter the gap between the two, the better: if something goes wrong, you know exactly which change caused it, because only one has gone in.

A frequent variant in practice inverts the order: deploy the branch first and merge afterwards, once you have checked that it works in production. It is what GitHub does internally. It has the advantage that main never comes to contain anything broken, and the cost of needing infrastructure to deploy arbitrary branches. The mechanics of that deployment — environments, promotion, rollback — are the subject of lesson 10-05; here we stay with the part that concerns Git.

  1. The complete cycle, with a graph

gitGraph
   commit id: "C1" tag: "deploy"
   branch feature/filter-by-label
   checkout feature/filter-by-label
   commit id: "F1"
   commit id: "F2"
   checkout main
   merge feature/filter-by-label tag: "deploy"
   branch fix/focus-after-delete
   checkout fix/focus-after-delete
   commit id: "B1"
   checkout main
   merge fix/focus-after-delete tag: "deploy"
   branch feature/csv-export
   checkout feature/csv-export
   commit id: "E1"
   commit id: "E2"
   checkout main
   merge feature/csv-export tag: "deploy"

Compare it mentally with the graph in the previous lesson. There is no twin track here, no bridges between permanent branches, no stabilisation phase. A straight line with short branches hanging off it, and a deployment at every meeting point.

Ana's complete cycle, from start to finish:

# 1. Start from an up-to-date main
git switch main
git pull

# 2. A descriptively named branch
git switch -c feature/filter-by-label

# 3. Work and push often
git commit -am "Add the label selector to the top bar"
git push -u origin feature/filter-by-label
# ... more commits, more pushes

# 4. Open the PR (as a draft if it is not ready yet)

# 5. Review: apply the comments on the same branch
git commit -am "Keep the filter when the page is reloaded"
git push

# 6. CI green + approval -> merge from the platform

# 7. Clean up
git switch main
git pull
git branch -d feature/filter-by-label

One detail that matters more than it seems: the branch should last days, not weeks. If a GitHub Flow branch has been open for three weeks, the model has stopped working: you have rebuilt a long-lived branch under another name, and with it all the late-integration problems we criticised in the previous lesson. Lesson 07-05 will take this idea to its extreme.

Keeping the branch up to date

Since main moves on while you work, and the branch is short, this is usually trivial:

git switch feature/filter-by-label
git fetch origin
git rebase origin/main            # linear history
# or
git merge origin/main             # no rewriting

Many teams configure the platform to require the branch to be up to date with main before it can be merged. It is an additional guarantee (CI will have tested the real combination) in exchange for having to update the branch whenever somebody gets in ahead of you. In teams with a lot of activity, that requirement creates a continuous race that is solved with a merge queue, which we shall see in lesson 07-06.

  1. The constraint that holds everything up: main always deployable

This is the section to really understand. The rest is procedure.

GitHub Flow looks simpler than Git Flow because it has fewer branches. But it is not free: it has swapped process complexity for a very strong constraint. Git Flow let you have develop broken for three days because stabilisation happened afterwards, on the release branch. GitHub Flow removes that safety net. There is no release branch. There is no subsequent QA phase. main is what gets deployed, full stop.

Think about what that implies:

  • If main is broken, nobody can deploy. Neither the person who broke it nor anybody else. The whole team is blocked.
  • If main is broken and it goes unnoticed, it gets deployed broken.
  • And if main is broken, you cannot release an urgent fix either, because the fix would go out accompanied by whatever is broken.

Hence this model's most important informal rule: fixing main is the team's top priority, ahead of any feature. If main's CI goes red, whatever is being done stops until it is green again. Many teams apply an automatic revert policy: if main breaks and is not fixed within ten minutes, the guilty commit is reverted (git revert, lesson 05-06) and it gets investigated calmly on a branch. Revert first, understand later.

And another, less obvious consequence: the size of each deployment drops a great deal. If you deploy after every PR, each deployment contains one change. When something fails in production, the list of suspects has one item on it. Compare that with deploying a quarterly release with a hundred and twenty changes inside it: when something goes wrong, the archaeology begins. That reduction of risk per deployment is the model's real benefit, and it is what compensates for the constraint.

  1. What it demands in return

The constraint of rule 1 does not hold itself up. It demands three things, and if any of them is missing the model does not work: it degrades into "we push to main and pray".

Demand 1: reliable automated tests

This is the indispensable condition. Nobody can manually guarantee that main is deployable after every merge: you need a test suite that runs on every push and every PR (lesson 07-06).

And reliable is the key word. A suite that fails randomly one run in ten — what is called a flaky test — is worse than having no tests, because it teaches the team to ignore failures. The moment somebody says "run it again, it fails sometimes", the signal is dead and with it the guarantee that main is deployable.

Demand 2: small pull requests

We already quantified this in lesson 07-02: above a few hundred lines, review stops finding defects. In a model where review is the last barrier before production, that is risk, plain and simple.

Besides, a small PR gets reviewed the same day, merged the same day and deployed the same day. A thousand-line PR waits three days for somebody to have a gap, and in those three days it accumulates conflicts.

Demand 3: frequent integration

Branches of days, not weeks. It is the same idea as the previous demand seen from the angle of time rather than size: the longer a branch lives apart, the more it diverges, the more conflicts it generates and the longer the value takes to reach the user.

And if the work is genuinely large and cannot be broken up into small PRs? There is an answer — feature flags and branch by abstraction — and it is one of the central topics of lesson 07-05.

The trap of adopting the model without the demands

It is worth saying plainly, because it is the most common failure: a team reads that GitHub Flow is "simpler", removes develop and the release branches, and does not invest in automated tests. The result is not simplicity: it is that main is broken half the time and nobody dares to deploy. They have taken away Git Flow's safety net without building GitHub Flow's.

GitHub Flow is not Git Flow minus branches. It is Git Flow minus branches plus automation.

  1. The merge method: merge commit, squash or rebase

When closing a pull request you have to decide how those commits go into main. The platforms offer three options, and the choice determines what the project's history will look like for ever.

You already know all of this from modules 3 and 5: here we just look at what each option produces in the context of a PR.

Option A: an ordinary merge (Create a merge commit)

Equivalent to git merge --no-ff (lesson 03-03). All the branch's commits are preserved and a merge commit is added.

gitGraph
   commit id: "C1"
   branch branch
   checkout branch
   commit id: "F1"
   commit id: "F2"
   commit id: "F3"
   checkout main
   merge branch id: "M"

Option B: Squash and merge

Equivalent to the merge --squash of lesson 03-04. All the branch's commits are condensed into a single one on main, and the original branch disappears from the history.

gitGraph
   commit id: "C1"
   commit id: "F (all squashed)"

Option C: Rebase and merge

Equivalent to a git rebase (lesson 05-01) followed by a fast-forward. The commits are rewritten on top of the tip of main, in a straight line and with no merge commit.

gitGraph
   commit id: "C1"
   commit id: "F1'"
   commit id: "F2'"
   commit id: "F3'"

The comparison

Aspect Merge commit Squash Rebase and merge
Commits in main per PR All of them + 1 merge Exactly 1 All of them
History With branching Linear Linear
Can you see which commits were one PR? Yes, from the merge commit Yes, it is a single commit No, they blend in
git log --first-parent One line per PR: an excellent summary The same as git log Adds nothing
git bisect (06-02) May land on broken intermediate commits Optimal: each step is a complete PR May land on broken intermediate commits
Reverting the whole PR git revert -m 1 <merge> git revert <commit>, trivial You have to revert N commits
git blame (06-03) Points at the original commit, with its context Points at the squashed commit: the detail is lost Points at the original commit
Historical detail Maximum Minimum: the step by step is lost Maximum
Rewrites hashes No Yes Yes
Demands discipline in the commits Yes: they stay on show No: the tidying is automatic Yes

Practical observations on each one:

Squash is the most popular in teams using GitHub Flow, and for a very specific reason: it absorbs untidy commits. The branch's wip, fixed and now it works disappear and what is left in main is one clean line per integrated change. The price is real: you lose the detail of how the change was built, and in a large PR the resulting commit is an enormous block that git blame cannot break down. It is the right choice if the PRs are small; if they are large, squashing makes things worse.

The ordinary merge preserves everything, but it only adds value if the branch's commits were well built (lesson 05-02). If they were not, it pollutes main with permanent noise. Its great advantage is git log --first-parent main: a perfect summary of the project, one line per PR.

Rebase and merge gives a linear history with no merge commits, but it completely loses the grouping: there is no way to know which commits arrived together, and reverting "that PR" means reverting N commits by hand. It is the least used of the three.

And now the important point:

Choose one and apply it always. A repository where every PR is merged with a different method has a history that cannot be read consistently: --first-parent means nothing, bisect gives uneven results and nobody knows what to expect. The specific decision and its consequences for the history are the content of lesson 08-02: Keeping a Clean History, where we shall deal with history policy in full. Here it is enough to know that the decision exists, that it is taken once, and that the platforms let you disable the methods you do not want so that nobody presses the wrong button.

  1. Versions without release branches: tags on main

With no release/*, how do you know what was released and when? With tags on main (lesson 05-05).

The idea is simple: every deployment to production gets tagged. The tag does not change what is in the repository; it adds a stable name to a commit so that you can come back to it.

# An annotated tag on the deployed commit
git switch main
git pull
git tag -a v2.4.0 -m "Version 2.4.0: filter by label and CSV export"
git push origin v2.4.0

Since there is no stabilisation phase, many teams that deploy frequently abandon SemVer for the application — there is not much point deciding whether a deployment is a minor or a patch when there are four a day — and use date-and-sequence tags:

git tag -a deploy-2026-08-01.1 -m "Deployment to production"
git push origin deploy-2026-08-01.1
Scheme Example When to use it
SemVer v2.4.0 Libraries, public APIs, anything with a compatibility contract
Date + sequence deploy-2026-08-01.1 Web applications with several deployments a day
Incrementing number build-1482 When the number is generated by CI itself

What is worth keeping is SemVer for libraries, and ui-components is exactly that case: other projects depend on it and need to know whether a version breaks compatibility.

Tags are also the natural mechanism for three everyday things:

# What went in between the last two deployments?
git log --oneline v2.3.0..v2.4.0

# Which deployment did this commit go out in?
git describe --contains <hash>

# Go back to a deployment's exact state to debug it
git switch --detach v2.3.0

And a third piece the platforms offer: deployment environments, a record of which commit is live in each environment. It is useful, but managing it is the subject of lesson 10-05; from Git's point of view, what there is is a tag and a reference.

And if something urgent has to be fixed?

This is where GitHub Flow shines, because it needs no special mechanism. The urgent fix follows exactly the same path as any other change:

git switch main && git pull
git switch -c fix/task-loss-when-filtering
# fix it
git commit -am "Fix the loss of tasks when two filters are applied"
git push -u origin fix/task-loss-when-filtering
# PR, quick review, CI, merge, deploy

Twenty minutes from start to finish. In Git Flow that same thing required a class of branch of its own, two merges and a patch tag. Deployment speed makes hotfix/* unnecessary: when deploying costs minutes, every fix is a hotfix.

  1. Protected branches: the mechanism that enforces the flow

Everything above is agreement. And as we saw in lesson 06-01 when talking about client-side hooks, agreements that depend on human discipline fail on a Friday at seven in the evening.

The piece that turns GitHub Flow into something real is the protected branch: a set of rules the server applies to a specific branch and that nobody can bypass from their laptop.

The usual protections on main:

Protection What it prevents Which rule of the flow it upholds
Forbid direct pushing git push origin main from a laptop Rules 4 and 5: everything goes through a PR
Require N approvals Merging without review Rule 5
Require green checks Merging with CI red Rule 1: main deployable
Require the branch to be up to date with main Merging without having tested the real combination Rule 1
Dismiss approvals when new commits arrive Approving one version and merging another Rule 5
Forbid force pushes git push --force onto main The golden rule (05-06)
Forbid deleting the branch Deleting main by accident Common sense
Require conversations to be resolved Merging with blocking comments still open Rule 5

When Ana tries to push directly to a protected main, the rejection comes from the server:

git push origin main
remote: error: GH006: Protected branch update failed for refs/heads/main.
remote: error: Required status check "tests" is expected. Changes must be
remote: made through a pull request.
To git@git.example.com:team/task-manager.git
 ! [remote rejected] main -> main (protected branch hook declined)
error: failed to push some refs to 'git.example.com:team/task-manager.git'

Look at the last line in brackets: protected branch hook declined. That is literally a server hook rejecting the update of the reference. It is the pre-receive/update family that lesson 06-01 announced, and it is also the answer to the question left open there: this cannot be bypassed with --no-verify, because --no-verify only affects the hooks that run on your machine. Here the server decides.

We shall develop it in full in lesson 07-06, including how a status check becomes a mandatory requirement. For now, the idea to hold on to: GitHub Flow with protected branches is a process; GitHub Flow without them is a recommendation.

A note on organisational design: almost every platform allows certain roles to bypass the protections. It is tempting to leave that door open "just in case", but it is worth thinking twice. The exception always gets used at the worst possible moment — a production incident, in a hurry and short of sleep — which is exactly when the checks are most needed.

  1. When GitHub Flow fits

Condition Why it fits
A web application or SaaS You control the deployment; there are no versions in the user's hands
A single version in production There is nothing to maintain in parallel, so develop is surplus
Frequent, cheap deployment It makes hotfix/* unnecessary: every fix arrives in minutes
Solid automated tests It is the indispensable condition of rule 1
A small or medium team Few simultaneous branches, few integration conflicts
An established review culture The PR is the only barrier before production

Typical examples: internal web applications, SaaS, APIs deployed by the team itself, static sites, internal tools. And the specific case in this course: app.example.com, the cloud version of task-manager.

It is also, by a distance, the most used model today, and it is the one you will find by default in most open source projects: an outside contributor makes a fork (lesson 07-01), opens a branch, sends a PR against main and that is that. No develop, no release branches, no more protocol than necessary.

  1. Its limits

Being honest about the limitations is what allows you to choose well.

Limit 1: it is no good for maintaining old versions

This is the fundamental limit. If you have clients on 1.4 and on 2.0 and both need security fixes, a single long-lived branch cannot represent two simultaneous production states. There is no way to fix 1.4 from main, which is already on 2.0.

The practical solution is a hybrid: GitHub Flow for day-to-day development plus maintenance branches created from the tags whenever they are needed:

# Only when a client needs it
git switch -c support/1.4 v1.4.0
git cherry-pick <security-fix>
git tag -a v1.4.3 -m "Version 1.4.3: security patch"
git push -u origin support/1.4 --follow-tags

It is exactly what the task-manager team does for the installable version. And note the nuance: it is not "switching to Git Flow", it is adding the specific missing piece when it is needed, which is always better than adopting a whole model up front.

Limit 2: it demands technical maturity that not every team has

Without reliable tests and without fast CI, the model degrades quickly. A team without that foundation is safer with Git Flow, where the release branch gives a margin for manual stabilisation. It is an uncomfortable but true conclusion: the simpler model demands more maturity, not less.

Limit 3: it says nothing about what to do with large work

A database migration, a rewrite of the interface, an architectural change. They do not fit into a three-day branch and they cannot be integrated half-finished into a main that has to be deployable. GitHub Flow offers no answer; it has to be brought in from outside, and that answer — feature flags and branch by abstraction — is one of the topics of the next lesson.

Limit 4: regulated environments, or ones with formal approval

If every release requires a quality manager's sign-off, formal documentation or an authorised deployment window, the "merge and deploy" of rule 6 is not applicable as it stands. It can be adapted (integrate continuously, deploy in windows), but then main accumulates undeployed changes and part of the benefit is lost.

Common Mistakes and Tips

Mistake 1: adopting GitHub Flow without automated tests. Git Flow's net is taken away without building your own. main ends up broken half the time.

Mistake 2: three-week branches. That is a long-lived branch under another name, with all the late-integration problems the model was meant to avoid.

Mistake 3: leaving main broken and carrying on working. It blocks the whole team, urgent fixes included. Fixing main is the absolute priority; if it takes more than a few minutes, revert.

Mistake 4: not protecting main. Without protected branches the model is a recommendation that somebody will bypass. With them it is a process.

Mistake 5: mixing merge methods. Some PRs with squash, others with a merge commit, others with rebase. The resulting history cannot be read consistently.

Mistake 6: squashing enormous PRs. Squash works with small PRs. Squashing a thousand lines produces a commit that git blame cannot break down and that nobody will be able to understand a year from now.

Mistake 7: not tagging the deployments. Without tags there is no way to know what was in production last Tuesday, nor to go back to it to debug.

Mistake 8: creating the branch without updating main first. git switch main && git pull first, always. Starting from a week-old main generates avoidable conflicts.

Mistake 9: living with unstable tests. A test that fails one time in ten teaches the team to ignore the red, and with that the guarantee of rule 1 dies. Fix it or remove it, but do not ignore it.

Tip 1: PRs of under 200 lines. It is what has the biggest impact on the speed and quality of the flow (lesson 07-02).

Tip 2: open the PR on day one, as a draft. CI starts testing sooner and somebody can correct your course in time.

Tip 3: delete the branch when merging. Turn it on in the platform and use git fetch --prune locally. A repository with two hundred dead branches is unusable.

Tip 4: disable in the platform the merge methods you do not use. It is more reliable than trusting that nobody presses the wrong button.

Tip 5: measure the lifetime of your branches. If the average is over a week, you are not doing GitHub Flow, whatever you call it.

Tip 6: add maintenance branches only when a real client needs them. Do not adopt the whole of Git Flow for a problem you may never have.

Exercises

Exercise 1: the GitHub Flow cycle

On a new repository with index.html, app.js and styles.css:

  1. Create main with three initial commits and tag it as v2.3.0.
  2. Simulate three complete cycles: a branch from main, two commits, back to main with --no-ff, delete the branch and add a deployment tag.
  3. Use branch names following the project's conventions: one feature/, one fix/ and one docs/.
  4. Show the graph and check that it resembles the one in section 3.
  5. Run git log --oneline --first-parent main and explain what information it gives you.

Exercise 2: comparing the three merge methods

  1. Create a branch feature/export with four commits, two of them clearly noise (wip, fixed).
  2. Make three copies of the repository at /tmp/method-merge, /tmp/method-squash and /tmp/method-rebase.
  3. In each copy, integrate the branch with the corresponding method (merge --no-ff, merge --squash + commit, rebase + merge --ff-only).
  4. Compare across the three: git log --oneline, git log --oneline --first-parent and the number of commits in main.
  5. In each copy, try to revert the whole feature with a single command. In which is it possible?
  6. Run git blame app.js in all three and comment on the differences.

Exercise 3: the hotfix simulation

  1. Starting from the exercise 1 repository, simulate a production bug: create fix/task-loss from main, fix it, merge it and tag the deployment.
  2. Count how many commands were needed from the initial switch to the tag.
  3. Compare that number with the equivalent hotfix/* procedure in Git Flow (lesson 07-03, section 7). How many steps have you saved, and which Git Flow step simply does not exist here?
  4. Now the other side: simulate that a client is still on v2.3.0 and needs that same fix without receiving anything else. Create support/2.3 from the tag, bring the fix over with cherry-pick and tag v2.3.1.
  5. Explain why step 4 is not part of GitHub Flow and what that tells you about when the model falls short.

Solutions

Solution 1:

mkdir /tmp/github-flow && cd /tmp/github-flow
git init -qb main
printf '<h1>Task Manager</h1>\n' > index.html
git add . && git commit -q -m "Add the main page"
printf 'const tasks = [];\n' > app.js
git add . && git commit -q -m "Add the initial application state"
printf 'body { margin: 0; }\n' > styles.css
git add . && git commit -q -m "Add the base stylesheet"
git tag -a v2.3.0 -m "Version 2.3.0"
# Cycle 1
git switch -qc feature/filter-by-label
echo "function filterByLabel(l) { /* ... */ }" >> app.js
git commit -qam "Add filtering by label"
echo ".filter { display: flex; }" >> styles.css
git commit -qam "Style the filter bar"
git switch -q main
git merge -q --no-ff feature/filter-by-label -m "Merge feature/filter-by-label (#101)"
git branch -qd feature/filter-by-label
git tag -a deploy-2026-08-01.1 -m "Deployment to production"
# Cycle 2
git switch -qc fix/focus-after-delete
echo "// fixed: return the focus to the field after deleting" >> app.js
git commit -qam "Return the focus to the input field after deleting"
git switch -q main
git merge -q --no-ff fix/focus-after-delete -m "Merge fix/focus-after-delete (#102)"
git branch -qd fix/focus-after-delete
git tag -a deploy-2026-08-01.2 -m "Deployment to production"
# Cycle 3
git switch -qc docs/installation
printf '# task-manager\n\n## Installation\n\nOpen index.html.\n' > README.md
git add . && git commit -q -m "Document the installation in the README"
git switch -q main
git merge -q --no-ff docs/installation -m "Merge docs/installation (#103)"
git branch -qd docs/installation
git tag -a deploy-2026-08-02.1 -m "Deployment to production"
# 4 and 5
git log --graph --oneline --decorate --all
git log --oneline --first-parent main
c8a2f1e Merge docs/installation (#103)
9d4b7c3 Merge fix/focus-after-delete (#102)
2f8e1a6 Merge feature/filter-by-label (#101)
7b3c9d2 Add the base stylesheet
4a1e6f8 Add the initial application state
1c5d2b9 Add the main page

--first-parent gives one line per merged PR: it is the project's readable summary. That is the main benefit of merging with an ordinary merge commit.

Solution 2:

cd /tmp/github-flow
git switch -qc feature/export
echo "function exportTasks() { /* ... */ }" >> app.js
git commit -qam "Add the skeleton of the export"
echo "// wip" >> app.js
git commit -qam "wip"
echo "function generateCSV() { /* ... */ }" >> app.js
git commit -qam "Generate the CSV content"
echo "// separator fixed" >> app.js
git commit -qam "fixed"
git switch -q main
for m in merge squash rebase; do cp -r /tmp/github-flow /tmp/method-$m; done
# Ordinary merge
cd /tmp/method-merge
git merge --no-ff feature/export -m "Merge feature/export (#104)"
git log --oneline main | head -6
# Squash
cd /tmp/method-squash
git merge --squash feature/export
git commit -q -m "Add the CSV export (#104)"
git log --oneline main | head -6
# Rebase
cd /tmp/method-rebase
git switch -q feature/export
git rebase -q main
git switch -q main
git merge -q --ff-only feature/export
git log --oneline main | head -6
# 4. Comparison
for m in merge squash rebase; do
  echo "== $m: $(git -C /tmp/method-$m rev-list --count main) commits in main"
  git -C /tmp/method-$m log --oneline --first-parent -3 main
done
# 5. Reverting the whole feature
git -C /tmp/method-merge revert -m 1 HEAD --no-edit          # works
git -C /tmp/method-squash revert HEAD --no-edit              # works
git -C /tmp/method-rebase revert HEAD~3..HEAD --no-edit      # 4 reverts needed

With a merge commit and with squash, one command is enough. With rebase you have to revert each commit, because nothing indicates that they were a set.

# 6. blame
for m in merge squash rebase; do
  echo "== $m"; git -C /tmp/method-$m blame --date=short -- app.js | tail -4
done

In squash, every line of the feature points at the same commit and the same message: the information about which part was done at which step has been lost.

Solution 3:

cd /tmp/github-flow
git switch -q main
git switch -qc fix/task-loss
echo "// fixed: do not empty the array when combining two filters" >> app.js
git commit -qam "Fix the loss of tasks when two filters are applied

Refs GT-341."
git switch -q main
git merge -q --no-ff fix/task-loss -m "Merge fix/task-loss (#105)"
git branch -qd fix/task-loss
git tag -a deploy-2026-08-02.2 -m "Urgent deployment to production"

Six commands. In Git Flow: create from main, fix, bump the patch version, merge into main, tag, merge into develop as well, delete in two places. The step that does not exist here is the second merge, and it is precisely the one most often forgotten and the most expensive when it is.

# 4. Maintaining an old version: the model's limit
FIX=$(git log --format=%h --grep='loss of tasks when two filters' -1)
git switch -qc support/2.3 v2.3.0
git cherry-pick "$FIX"
git tag -a v2.3.1 -m "Version 2.3.1: patch for clients on 2.3"
git switch -q main
git log --graph --oneline --all --decorate | head -20
# 5.
# support/2.3 is a SECOND long-lived branch: it contradicts the premise
# of GitHub Flow. The model assumes a single version in production.
# As soon as there are two live production states, a piece is needed
# that the model does not have, and it has to be borrowed from Git Flow.

Conclusion

GitHub Flow is the dominant model today, and its simplicity is real but conditional. The essentials:

  • A single long-lived branch, main, always deployable. Everything else is short branches that come off main, get reviewed in a PR and go back into main. There is no develop, no release/*, no hotfix/*, and no double integrations to forget.
  • The six rules: main deployable; descriptive branches off main; push constantly; open the PR at any time; merge only after review; deploy immediately afterwards.
  • "main always deployable" is the constraint that holds the model up, and it is not free: if main breaks, the whole team is blocked, urgent fixes included. Fixing main is the absolute priority; if it drags on, revert.
  • In exchange for removing branches, the model demands reliable automated tests, small PRs and frequent integration. GitHub Flow is not Git Flow minus branches: it is Git Flow minus branches plus automation. Without that foundation, it degrades.
  • The merge method — merge commit, squash or rebase — determines the history for ever: squash absorbs untidy commits and is optimal for bisect, the merge commit preserves the detail and enables --first-parent, rebase loses the grouping. Choose one and apply it always; the complete policy is in lesson 08-02.
  • With no release branches, tags on main record what was deployed and when: SemVer for libraries such as ui-components, date and sequence for applications with several deployments a day.
  • Urgent fixes need no mechanism of their own: when deploying costs minutes, every fix is a hotfix.
  • Protected branches turn the agreement into a process: forbid direct pushing, require approvals and green checks. And that protected branch hook declined is a server hook, precisely what --no-verify cannot touch.
  • It fits web and SaaS with a single version in production, frequent deployment and good tests. Its limit is maintaining old versions: for that you need to add support branches from the tags.

There is one question this model leaves open. GitHub Flow asks for short branches, but how short? And what do you do with work that does not fit into three days? There is a school of thought that answers by taking the idea to its extreme: integrate into the trunk at least once a day, every person, always, and decouple deployment from release with feature flags so that this is possible even with half-finished work. That is the approach of lesson 07-05: Trunk Based Development, where we shall also finally compare the three flows in a single table and build a decision tree for choosing between them.

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