At the close of module 9 we left Ana, Bruno, Carla and Diego with a firm command of Git on task-manager: they know how to build history, rewrite it with judgement, collaborate with a process and get out of trouble. But task-manager is four files and four people. The real world is bigger and stranger.

This lesson does not teach any new commands. It teaches something that at this point is worth more: judgement. We are going to look at how four very different kinds of project use Git — from the largest and oldest one to one exactly like yours — and to draw out of each what problem its workflow solves, what price it pays for it and what it takes to make it work.

Let me give away the conclusion we are after, because it is the only one worth taking home: there is no such thing as "the correct Git workflow". There is the workflow that fits the size of the team, the trust between its members, the release rhythm and the cost of getting things wrong. Copying the Linux kernel's workflow into a team of four is as absurd as coordinating the kernel through the pull requests of a web platform.

Everything described here is public and well documented: the kernel's workflows are written up in its own documentation, open source projects publish their contribution guides, and enterprise monorepo practices have been described in articles and technical talks for years. When I quote a magnitude, I will give it as an approximate order of magnitude, never as an exact figure: what matters is the scale, not the number.

Contents

  1. How to read a case study
  2. Case 1: the Linux kernel, the email workflow
  3. The maintainer hierarchy and the "tree of trees"
  4. Why the kernel does not use platform pull requests
  5. What the kernel model demands of a contributor
  6. Case 2: a medium-sized open source project
  7. The social infrastructure of an open project
  8. Case 3: a company with a monorepo
  9. Case 4: a small product team (ours)
  10. The four models, compared
  11. The cross-cutting lesson: a workflow is chosen, not inherited

  1. How to read a case study

Before looking at any project it is worth settling on the questions. Every Git workflow, however complex it may look, answers five questions:

Question What it determines
How does someone outside the core propose a change? The proposal mechanism: email, pull request, direct branch
Who decides that a change goes in? The authority structure: one maintainer, two reviewers, a directory owner
Which branch is the work done on, and for how long? The branching model and its half-life
What does the resulting history look like? Whether it is linear, whether it has merges, whether every commit builds
What does it take to make that work? The infrastructure and the discipline required

The fifth is the one that is almost always ignored, and the one that explains why a workflow copied from somewhere else fails. A workflow is not just a sequence of commands: it is a sequence of commands plus the tools, the rules and the habits that hold it up.

We are going to answer those five questions for each case.

  1. Case 1: the Linux kernel, the email workflow

The Linux kernel is the project Git was written for, in 2005, when the team lost access to the tool it had been using until then. It is also the project that still uses the oldest workflow and, for almost everybody, the most surprising one: changes are proposed by email, on public lists, in the form of text patches.

The magnitudes, as orders of magnitude: tens of millions of lines of code, tens of thousands of files, more than twenty years of history in Git (and more than thirty as a project), and on the order of thousands of people contributing each year. No small team looks anything like this, and that is precisely why the case is instructive: it shows what happens when you take Git collaboration to its limit.

The life of a patch

flowchart TD
    A["Developer<br/>makes local commits"] --> B["git format-patch<br/>generates .patch files"]
    B --> C["git send-email<br/>to the subsystem list"]
    C --> D["Public review<br/>on the mailing list"]
    D -->|"changes requested"| E["New version: v2, v3…"]
    E --> C
    D -->|"accepted"| F["Maintainer:<br/>git am applies the patch"]
    F --> G["Subsystem tree"]
    G --> H["git request-pull<br/>to the maintainer above"]
    H --> I["Main tree (Linus)"]

Notice one important detail: the patch travels as text, not as a reference to a repository. Whoever receives it does not need to add a remote, or have access to the sender's repository, or even know whether that repository exists. They receive an email, and that email contains the entire change.

git format-patch: turning commits into emails

# Generates one .patch file for every commit I have on top of main
git format-patch main
0001-net-fix-memory-leak-in-the-driver.patch
0002-net-add-regression-test.patch

Each file is a complete email: headers, a subject taken from the first line of the commit message, the body of the message, and the diff at the end. If we apply this to our own project to see it from the inside:

git format-patch main --stdout | head -30
From 8a1f6c3d4e5b6a7c8d9e0f1a2b3c4d5e6f7a8b9c Mon Sep 17 00:00:00 2001
From: Ana Ferrer <[email protected]>
Date: Fri, 31 Jul 2026 13:11:58 +0200
Subject: [PATCH 1/2] GT-142 show the pending counter

A counter is added in the header with the number of tasks that are
not completed, recalculated whenever a task is ticked or unticked.

Signed-off-by: Ana Ferrer <[email protected]>
---
 app.js     | 12 ++++++++++++
 index.html |  3 ++-
 2 files changed, 14 insertions(+), 1 deletion(-)

That From 8a1f6c3d... on the first line is not a real email header: it is the hash of the original commit, which Git stores there so that whoever applies it can tell where it came from. The date Mon Sep 17 00:00:00 2001 is a historical sentinel value that Git always writes; it means nothing.

Options that genuinely get used:

# Number them and add a cover letter (0000-cover-letter.patch)
git format-patch main --cover-letter --numbered

# Mark this as the second version of the series after review
git format-patch main -v2

# Only the last three commits
git format-patch -3

# With the branch name in the subject, useful for long series
git format-patch main --subject-prefix="PATCH task-manager"

The cover letter (--cover-letter) is the 0/N email of the series: it explains the set as a whole, not each individual patch. In the kernel it is where the case for the complete change is argued, and where what has changed since the previous version of the series is summarised.

git send-email: sending them

git send-email [email protected] \
               [email protected] \
               0001-*.patch 0002-*.patch

git send-email sends them as an email thread: the cover letter is the root message and the patches hang off it as replies. It requires an SMTP server to be configured:

git config --global sendemail.smtpServer smtp.example.com
git config --global sendemail.smtpUser [email protected]
git config --global sendemail.smtpEncryption tls
git config --global sendemail.smtpServerPort 587

It is the command more people have installed and never actually used. But it is worth knowing: when somebody tells you "send me the patch", this is what they mean.

git am: applying the patch you receive

At the other end, the maintainer saves the email and applies it:

# Apply a series of patches in order
git am 0001-*.patch 0002-*.patch

# Apply straight from a mailbox
git am /path/to/mailbox.mbox

# Add my own sign-off when applying it (standard practice in the kernel)
git am --signoff 0001-*.patch

am stands for apply mailbox. It creates one commit per patch preserving the original author and putting the maintainer down as committer. Here, for the first time, the author/committer distinction that appeared in the data model (lesson 01-04) makes practical sense: the kernel uses it constantly, because whoever writes the code and whoever integrates it are almost never the same person.

If a patch does not apply cleanly:

# See where it failed
git am --show-current-patch=diff

# Try applying with more leeway using the blob information
git am -3

# Fix by hand, mark as resolved and carry on
git add .
git am --continue

# Or abandon the whole series and go back to the previous state
git am --abort

git am -3 (three-way merge) is the one that rescues most cases: it uses the blob hashes the patch carries embedded in it to reconstruct the context and perform a real merge instead of a textual application. It is exactly the three-way mechanism of lesson 03-03, applied to a patch.

git request-pull: asking someone to integrate your tree

When a subsystem maintainer already has the patches in their repository and wants the level above to integrate them, they do not send patches: they send a pull request by email, generated by Git:

git request-pull v6.10 https://git.example.com/network-subsystem.git for-6.11
The following changes since commit 3f2a1b9c...:

  Linux 6.10 (2026-06-14 18:02:11 -0700)

are available in the Git repository at:

  https://git.example.com/network-subsystem.git for-6.11

for you to fetch changes up to 9d4e7f2a...:

  net: add regression test (2026-07-28 11:40:03 +0200)

----------------------------------------------------------------
Ana Ferrer (2):
      net: fix memory leak in the driver
      net: add regression test

 drivers/net/example.c | 24 +++++++++++++++++-------
 1 file changed, 17 insertions(+), 7 deletions(-)

This is the original pull request, the real one: an email that says "in this repository, on this branch, from this base commit, there are these changes; come and get them". The web platforms we use every day did not invent the concept: they put an interface on top of it. We will come back to request-pull in lesson 10-02, as a bridge between the two worlds.

  1. The maintainer hierarchy and the "tree of trees"

The kernel does not have one repository: it has hundreds. Each subsystem — networking, filesystems, graphics, a particular processor architecture — has its own public repository and its own maintainer.

flowchart BT
    D1["Contributor"] --> S1["A driver's tree"]
    D2["Contributor"] --> S1
    D3["Contributor"] --> S2["Another driver's tree"]
    S1 --> M1["Subsystem tree<br/>(networking)"]
    S2 --> M1
    S3["Subsystem tree<br/>(filesystems)"] --> T["Main tree"]
    M1 --> T
    S4["Subsystem tree<br/>(graphics)"] --> T
    T --> R["Release<br/>vX.Y"]

Information flows from the bottom upwards, on trust. Each level trusts the one below because it knows those people and has been reviewing their work for years, and answers to the one above for what it integrates. The main tree does not review every patch: it reviews pull requests from maintainers it trusts.

This has three technical consequences that are very visible in the history:

  1. The main history is full of merge commits between subsystem trees. It is not disorder: each merge documents "on this date I integrated this subsystem's work". The message of those merges is usually the text of the corresponding request-pull.
  2. Individual patches arrive already clean, because they have been discussed and rewritten on the list before entering any tree. Cleanliness is not achieved by rewriting afterwards: it is achieved by not letting the dirty stuff in.
  3. The rhythm is one of windows. There are periods in which the main tree accepts new changes and periods in which it only accepts fixes, closing with a release. That rhythm makes the work of every level below predictable.

Signed-off-by and the certificate of origin

Every kernel commit carries one or more lines at the end:

Signed-off-by: Ana Ferrer <[email protected]>
Signed-off-by: Bruno Salas <[email protected]>

It is not a cryptographic signature (that is git commit -S, lesson 08-05). It is a legal declaration: whoever adds it certifies that they have the right to contribute that code under the project's licence. It is called the Developer Certificate of Origin, it is a short public text, and it is added with:

git commit --signoff -m "..."
git am --signoff patch.patch

Each level of the hierarchy adds its own on integrating, so the commit ends up carrying the complete chain of custody: who wrote it and whose hands it passed through on its way to the main tree. It is social traceability written into the commit object itself.

  1. Why the kernel does not use platform pull requests

It is the obvious question. The answer is not "out of habit" or "out of resistance to change", although inertia does play its part. There are structural reasons:

Reason Explanation
Vendor independence Depending on one particular platform means its policy, its availability and its price constrain the project. Email can be served by anybody.
Review is the product On a list, the technical discussion is archived, it is quotable, it can be answered inline on the code itself, and it is read by people who were not following that change. It is review that is public by default, not private by default.
Scale of participants Thousands of people reviewing hundreds of simultaneous series in parallel works better with email filters than with the notifications of a web interface.
Nobody needs permissions To propose a change you do not need an account, or a fork, or anybody to grant you access to anything. Knowing the list address is enough.
The patch is self-contained It can be applied, saved, forwarded, quoted and archived without depending on any server still existing fifteen years from now.

And there are costs too, which are worth stating just as plainly:

  • The barrier to entry is high. Configuring outgoing email so that it does not mangle patches is a rite of passage famous for how painful it is.
  • There is no centralised state for "where has this proposal got to". External patch-tracking tools exist to soften that, but it is not the same as a dashboard.
  • Continuous integration is harder to tie to a specific proposal, because the proposal is an email, not a branch on a server.

In recent years tools have appeared that automate the tedious parts (sending series, retrieving them from the public archives, managing successive versions) without abandoning email. The trend is not to replace the workflow, but to make it less abrasive.

  1. What the kernel model demands of a contributor

This is the point in the lesson where the case stops being a curiosity and starts being useful to you, even if you never send a patch to the kernel.

Demand 1: every commit must be correct on its own. Not "the set works": each commit builds and passes the tests, because git bisect (lesson 06-02) has to be able to stop at any one of them. This forces you to split the work into coherent steps, and it is what turns a series of patches into something reviewable.

Demand 2: the message reads on its own. A maintainer receives the patch in an email, with no access to your ticket, no context from you and no way to ask you in the corridor. The message is the documentation of the change. Everything we saw in lesson 08-01 — the why before the what, the imperative, the body that explains the alternative that was discarded — is not a good practice here: it is a functional requirement.

Demand 3: a series is a narrative. Patches are ordered so that each one prepares the next: first the refactoring that does not change behaviour, then the behaviour change, then the tests. It is exactly the use of interactive rebase from lesson 05-02, with an editorial purpose.

Demand 4: you accept rewriting. Version 5 of a series is normal. It is redone with rebase -i, resent with -v5, and the cover letter explains what changed relative to v4. That is what git range-diff (lesson 07-02) is for, which compares two versions of the same series.

Applied to our project, this is how Ana would prepare to send a series in this style:

# 1. Clean up the series: reorder, squash the "fix typo" commits, rewrite messages
git rebase -i main

# 2. Check that every commit builds, one by one
git rebase main --exec "npm test"

# 3. Generate v2 and compare it with the v1 I already sent
git format-patch main -v2 --cover-letter
git range-diff main v1-final main

The --exec in the second step is a little-known gem: it runs that command after applying each commit of the rebase, and stops at the first one that fails. It is the mechanical way of guaranteeing demand 1.

  1. Case 2: a medium-sized open source project

Let us drop down a scale. We are talking about a project with hundreds of contributors, dozens of them regulars, a handful of maintainers, and a hosting platform (git.example.com in our fictional universe). A popular library, a framework, a well-known command line tool.

This is exactly Diego's model, the external contributor from lesson 07-01, multiplied by two hundred.

The workflow

flowchart LR
    A["Personal fork"] --> B["Topic branch"]
    B --> C["Pull request<br/>to the main repository"]
    C --> D["Automated CI"]
    C --> E["Human review"]
    D --> F{"All green?"}
    E --> F
    F -->|yes| G["Squash and merge<br/>into main"]
    F -->|no| B

None of this is new to you: it is the whole of module 7. What changes with scale is not the mechanics, but how much work the infrastructure does so that the maintainers do not drown.

The maintainer's arithmetic

The fact that explains everything else: in a project like this, review is the scarce resource. There are hundreds of people capable of writing a proposal and perhaps five with the right and the time to integrate it. Every minute a maintainer spends explaining how to run the tests, asking for a branch to be rebased or closing duplicate proposals is a minute not spent reviewing code.

Hence why the whole design of the process pursues one objective: that the proposal should arrive already in good shape.

  1. The social infrastructure of an open project

These are the concrete pieces, and they deserve attention because they are directly applicable to any repository, yours included.

CONTRIBUTING.md

A file at the root of the repository that platforms show automatically to anybody about to open a proposal. It contains what a maintainer is tired of repeating:

# How to contribute to task-manager

## Before you start
- Open an issue before writing code for a large change.
- Check whether an open proposal about the same thing already exists.

## Setting up the environment
    git clone https://git.example.com/team/task-manager.git
    npm install
    npm test

## Conventions
- Commit messages: Conventional Commits (feat:, fix:, docs:…).
- One proposal, one topic. If your branch touches two things, split it in two.
- Rebase onto `main` before asking for review; do not merge `main` into your branch.

## What to expect
- CI must be green before we review.
- A maintainer will reply within a few working days.
- We integrate with squash: your branch becomes a single commit on `main`.

Notice the last line. Saying up front which integration policy applies (merge, squash or rebase, lesson 08-02) heads off the most recurrent argument in proposals from newcomers.

Proposal and issue templates

Files inside a platform configuration directory that pre-fill the form. The proposal template is usually a checklist:

## What changes

<!-- Brief description of the change and the problem it solves -->

## Related ticket

Closes GT-

## Checks

- [ ] I have run `npm test` locally and it passes
- [ ] I have added tests for the new behaviour
- [ ] I have updated the README if the change affects usage
- [ ] My commits follow Conventional Commits

Their real effect is not documentary, it is psychological: whoever opens the proposal sees the empty boxes and it occurs to them to go through them before being asked.

Labels

A well-thought-out label system turns an unmanageable list of issues into a navigable map:

Label family Examples What it is for
Type bug, enhancement, documentation Classifying at a glance
State needs-review, awaiting-response, blocked Knowing whose turn it is
Difficulty good-first-issue, help-wanted Channelling newcomers
Area ui, storage, ci Routing to the right maintainer

The good-first-issue label (the name most platforms have standardised on) is the most profitable of the lot: it is how a project turns readers into contributors.

Automating the repetitive parts

In a project on this scale, robots do the work that in task-manager a person does:

name: Proposal checks

on:
  pull_request:
    types: [opened, synchronize, reopened]

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Check the format of the commit messages
        run: |
          git log --format=%s origin/main..HEAD | while read -r subject; do
            echo "$subject" | grep -qE '^(feat|fix|docs|refactor|test|chore)(\(.+\))?: .+' \
              || { echo "Non-conforming message: $subject"; exit 1; }
          done

      - name: Verify that the branch is up to date with main
        run: |
          git merge-base --is-ancestor origin/main HEAD \
            || echo "::warning::Rebase onto main before asking for review"

      - name: Tests
        run: npm ci && npm test

That git merge-base --is-ancestor is a plumbing command (lesson 09-06) that answers with an exit code: is origin/main an ancestor of HEAD? If it is, the branch is up to date.

What history this model produces

Almost always linear on main, with one commit per integrated proposal, a normalised message and a reference to the proposal number. It is the result of the squash policy, and it has an enormous virtue at this scale: git log --oneline on main reads like a changelog for the project, not like the diary of two hundred people.

The price, already discussed in lesson 08-02, is that the internal detail of each proposal is lost. In an open project that is accepted quite happily, because that detail still exists in the proposal itself, archived on the platform.

  1. Case 3: a company with a monorepo

A third model, radically different: one company, one repository, many products inside it. The web application, the data service, the mobile app, the shared libraries, the infrastructure definitions: all in the same directory tree and the same history.

Several large companies have publicly described this model over the years. The magnitudes, always as an order of magnitude: hundreds of thousands to millions of files, millions of commits, and thousands of people working on the same repository. At that scale, Git out of the box is not enough, and that is the subject of lesson 10-04. What interests us here is the why, not the how.

Why anybody chooses this

Reason 1: the atomic change across projects. If the shared library changes its interface and there are thirty consumers, in a monorepo that is a single commit that updates the library and all thirty at once. There is never an instant in which the repository is inconsistent. With separate repositories, that same change is thirty-one coordinated proposals, intermediate compatibility versions and weeks of work.

This is exactly the problem we left open in lesson 06-05 with ui-components: when the submodule changes, the pointer has to be updated in the parent repository, and for a while the two things do not line up. In a monorepo that problem does not exist because there are not two repositories.

Reason 2: global refactoring is possible. Renaming a function used across the whole company is a search and replace plus a commit. With separate repositories, it is a project.

Reason 3: a single version of everything. There is no "which version of the library does the billing service use". It uses the one that is on main, like everybody else.

Reason 4: visibility and reuse. Anybody can read any code, find out who maintains it and propose a change.

The price

Cost How it is paid
Git out of the box drowns Partial clones, sparse-checkout, specialised servers, build caches (10-04)
Everybody sees everything Can be unacceptable if there is code with confidentiality requirements
CI cannot run everything You need selective CI: working out what the change affects
In-house tooling Build system with a dependency graph, code ownership tools, migration automation
Nobody can just clone and start Onboarding newcomers requires training in the environment

Code ownership by directory

The key organisational piece. A text file in the repository declares who has to approve changes in each zone of the tree:

# CODEOWNERS
# Default rule: the platform team
*                           @platform

# Each team owns its directory
/services/billing/          @billing-team
/services/tasks/            @tasks-team
/libraries/ui-components/   @design-team @tasks-team

# Critical areas require additional approval
/infra/production/          @platform @security
/libraries/authentication/  @security

The platform reads that file, works out which directories each proposal touches and automatically assigns the mandatory reviewers. Combined with the protected branches of lesson 07-06, the result is that autonomy is recovered without giving up the single repository: each team is in charge of its own patch, but touching the authentication library requires security's sign-off.

It is the answer to the most frequent objection against the monorepo. "If everything is together, can anybody change anything?" Technically yes; in practice, not without the approval of whoever owns that zone.

Selective continuous integration

If the repository has a thousand projects, running the tests for all thousand on every proposal is impossible. The solution is to work out the affected set:

name: Selective CI

on: [pull_request]

jobs:
  detect:
    runs-on: ubuntu-latest
    outputs:
      affected: ${{ steps.calc.outputs.list }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - id: calc
        name: Which projects this proposal touches
        run: |
          # Files modified relative to the divergence point with main
          CHANGES=$(git diff --name-only origin/main...HEAD)
          # First directory level = project
          PROJECTS=$(echo "$CHANGES" | cut -d/ -f1-2 | sort -u | tr '\n' ' ')
          echo "list=$PROJECTS" >> "$GITHUB_OUTPUT"

  test:
    needs: detect
    if: needs.detect.outputs.affected != ''
    runs-on: ubuntu-latest
    steps:
      - run: echo "Testing only ${{ needs.detect.outputs.affected }}"

That origin/main...HEAD with three dots is the same one from the code review of lesson 07-02: the branch's changes since it diverged, not the differences with the current state of main. It is a detail that matters a great deal here: with two dots, any advance in main would make the pipeline believe the proposal touches projects it has not touched.

In practice, a serious system does not use directories but the build system's dependency graph: if ui-components changes, every project that depends on it is tested as well. But the principle is the same, and it always starts with a git diff --name-only.

  1. Case 4: a small product team (ours)

A fourth case: four people, one product, a release every few weeks. That is, task-manager. It is also, by a wide margin, the most common case: the vast majority of teams in the world look far more like this one than like the previous three.

Recapping what this team has built over the course, without repeating the detail:

  • One repository on git.example.com, plus ui-components as a submodule (06-05).
  • Short topic branches named after the ticket (GT-231-hidden-filter), integrated into main by way of a proposal and review (07-01, 07-02), with a workflow close to GitHub Flow (07-04).
  • Continuous integration on every proposal and a protected branch on main (07-06).
  • Conventional Commits and an agreed integration policy (08-01, 08-02).
  • Annotated tags with SemVer for releasing (05-05).
  • Diego, an external contributor, works by fork and proposal (07-01).

What is interesting about this case is what it does NOT have, and does not need:

Does not have Why it does not need it
A maintainer hierarchy Four people talk to each other; the hierarchy would be theatre
An email workflow They all have write access to the same server
CODEOWNERS Anybody can review any file, and it is healthy that they should
Selective CI The tests for the whole project take two minutes
Partial clones or sparse-checkout The repository weighs less than a photograph
Git LFS There are no large binaries… yet (10-03)
Long-lived release branches There is only ever one version in production at a time

Each of those absences is a design decision, not a shortcoming. Adding any of them "because serious projects do it" would make the workflow worse: more steps, more ceremony, more places to get things wrong, and zero problems solved.

This is the most expensive professional bias there is in this field: confusing the complexity of the process with the maturity of the team.

  1. The four models, compared

Linux kernel Medium open project Company monorepo Product team
Size Thousands of people, hundreds of subsystems Hundreds of contributors, few maintainers Hundreds or thousands of people, one repository 3–15 people
Proposal mechanism Patch by email (format-patch/send-email/am) Fork + pull request on a platform Branch in the central repository + proposal Branch in the central repository + proposal
Who decides Maintainer hierarchy based on trust The project's maintainers Directory owners (CODEOWNERS) Any teammate
Branching flow Independent trees merged upwards; integration windows Short topic branches on main Very short topic branches; main always deployable Short topic branches on main
Resulting history Many merges between trees; every commit valid on its own Linear, one commit per proposal (squash) Linear and extremely high volume Linear or nearly so, readable by eye
Rewriting Constant and expected (v2, v3…) before integrating Frequent before integrating Little: integration is fast and small Occasional, to tidy up the branch
Releasing Windows and a release candidate cycle SemVer tags Continuous, with no global version SemVer tags
What it takes Extreme discipline in commits and messages; a culture of public review; tolerance for rewriting CONTRIBUTING.md, templates, labels, reliable CI, maintainers with time In-house tooling, selective CI, Git scaling, code ownership Mutual trust, basic CI, agreed conventions
Fails when Somebody sends a patch with no context or without splitting it The maintainers get saturated or CI is slow and flaky The tooling cannot keep up with the repository Ceremony nobody needs gets added

Read it vertically, not horizontally: each column is a coherent system, in which every cell holds up the others. The kernel's extreme discipline is possible because there is a hierarchy of trust that demands it; the hierarchy is sustainable because the patches arrive impeccable. Taking a cell out of its column and putting it into another usually produces a disaster.

  1. The cross-cutting lesson: a workflow is chosen, not inherited

If I had to reduce this lesson to one sentence it would be this: a workflow is an answer to a specific set of constraints. Change the constraints and the correct answer changes.

The constraints that genuinely matter are four:

1. How much trust is there between the contributors? With high trust (an internal team), direct write access and light review work. With low or unknown trust (anybody on the internet), you need a mechanism where a proposal can arrive without permissions and approval is explicit: fork and proposal, or patch by email.

2. How much does a mistake in production cost? If redeploying costs thirty seconds, you can afford to integrate quickly and fix afterwards (07-05). If the mistake means recalling firmware from devices, you need windows, release candidates and stabilisation branches (07-03).

3. How many versions do you have alive at once? A single version in production allows for a single main. Maintaining three simultaneous versions with security fixes forces you into long-lived branches and systematic cherry-pick (05-03).

4. How big is the repository and how many people touch it? That is what decides whether you need the techniques of lesson 10-04 or whether Git out of the box will be more than enough for the next ten years.

The anti-pattern

The classic mistake always follows the same script:

Somebody reads an article about how an enormous company works. They propose adopting it. Five tools get installed, seven branch types get defined and a three-tier approval policy comes in. Two months later, in a team of five, integrating a two-line change takes three days and nobody remembers why.

The process of a five-thousand-person organisation is the scar left by problems that organisation had. If you do not have those problems, what you are copying is not a solution: it is somebody else's scar.

The healthy recipe

  1. Start with the simplest workflow that works. For almost everybody: short topic branches, proposals with review, a protected main, CI that passes in minutes.
  2. Add process only when something specific hurts. And let the conversation start with the pain, not with the tool: "production broke three times this month" is a reason; "we ought to use Git Flow" is not.
  3. Write the decisions down. In CONTRIBUTING.md or in the README.md. A workflow that lives only in two people's heads does not survive the third hire.
  4. Review it now and then. A process that suits four people may be insufficient for twelve and absurd when they are back down to four.

Common Mistakes and Tips

Mistake 1: copying the workflow of a project that does not look like yours. This is the central mistake of this lesson. Before adopting anything, ask what problem it solved in its original context and whether you have that problem.

Mistake 2: believing format-patch/am belongs in a museum. It is the most portable way there is of moving a change between two repositories without connecting them. It works for sending a fix to a colleague with no shared network, for moving a commit between two repositories that know nothing about each other, or for archiving a change as text. We pick it up again in lesson 10-02.

Mistake 3: confusing git request-pull with a platform's pull request. The first generates a text that you send by whatever means you like; the second is an object managed by a server, with state, comments and checks. Conceptually they are the same thing; operationally, they are not.

Mistake 4: thinking a monorepo is "everything thrown in together". A monorepo that works has more structure than a set of separate repositories: ownership by directory, an explicit dependency graph and selective CI. What it eliminates is not the order, but the coordination between repositories.

Mistake 5: using a project's prestige as a technical argument. "The kernel does it this way" is not a reason. The reason is why it does it that way, and whether that applies to your case.

Tip 1: learn to read another project's history. It is the fastest way of understanding how a team works before contributing:

git clone --filter=blob:none https://git.example.com/team/project.git
cd project

# Are there merges, or is the history linear?
git log --oneline --graph -40

# What proportion of the commits are merges?
git rev-list --count --merges HEAD
git rev-list --count HEAD

# What message convention do they use?
git log --format=%s -40

# Who really integrates? (committer, not author)
git log --format='%cn' -300 | sort | uniq -c | sort -rn | head

# Do they use Signed-off-by?
git log --format=%b -60 | grep -c 'Signed-off-by'

That --filter=blob:none on the clone is a partial clone: it brings down the complete graph but not the contents of the files. For inspecting a history it is perfect and vastly faster. It is explained in lesson 10-04.

Tip 2: before contributing to a project, read its CONTRIBUTING.md from top to bottom. It sounds obvious and hardly anybody does it. It is the difference between a proposal that gets integrated and one that sits waiting for six months.

Tip 3: if your team does not have its workflow written down, write it yourself. Half a page in the README.md: which branches exist, how they are named, how things are integrated, how releases are made. It is the contribution with the best effort-to-benefit ratio you can make.

Exercises

Exercise 1: moving a change with no shared remote

Ana and Bruno are on a train with no connectivity, sharing a local network. Ana has three commits on the branch GT-244-sort-by-priority that Bruno needs in his repository. There is no server within reach.

Describe two ways of achieving it with what you have learnt in the course, and explain when you would prefer each one.

Exercise 2: diagnosing a workflow from its history

You have cloned an unknown project and you get this data:

$ git rev-list --count HEAD
18452

$ git rev-list --count --merges HEAD
6127

$ git log --format=%s -8
Merge tag 'network-for-6.11' of git.example.com/network-subsystem
Merge tag 'filesystems-for-6.11' of git.example.com/filesystem-subsystem
net: fix memory leak in the driver
net: add regression test
Merge tag 'graphics-for-6.11' of git.example.com/graphics-subsystem
docs: update the contribution guide
Merge tag 'usb-for-6.11' of git.example.com/usb-subsystem
Merge tag 'audio-for-6.11' of git.example.com/audio-subsystem

$ git log --format='%an|%cn' -6
Ana Ferrer|Bruno Salas
Carla Vidal|Bruno Salas
Diego Rueda|Ana Ferrer
Ana Ferrer|Bruno Salas

Which of the four models does it correspond to? Justify your answer with three different pieces of evidence.

Exercise 3: choosing a workflow for three teams

For each situation, decide the working model and justify it in three or four lines. Say also what you would not add.

A. Five people, an internal web application, deploying several times a day, everybody in the same office, the repository weighs 40 MB.

B. An open source library with twenty regular contributors and around two hundred occasional ones a year. Two maintainers with little time. It is used in production in many places, so a broken version is a serious problem.

C. A company of 400 people, twelve products sharing four internal libraries. Today they have sixteen repositories and they spend their lives coordinating versions between them. Every change to a shared library takes weeks to reach all its consumers.

Solutions

Solution 1

Way A: git bundle (lesson 09-05).

# Ana: package the three commits into a file
git bundle create /tmp/GT-244.bundle main..GT-244-sort-by-priority

# (copy the file to Bruno's machine over the local network or on a USB stick)

# Bruno: check what the bundle requires and what it contains
git bundle verify /tmp/GT-244.bundle
git bundle list-heads /tmp/GT-244.bundle

# Bruno: fetch from it as though it were a remote
git fetch /tmp/GT-244.bundle GT-244-sort-by-priority:GT-244-sort-by-priority

Way B: format-patch + am (this lesson).

# Ana
git format-patch main -o /tmp/gt244-patches/

# Bruno
git checkout -b GT-244-sort-by-priority main
git am /tmp/gt244-patches/*.patch

When to use each:

bundle format-patch/am
What it carries Real Git objects, with their hashes The content of the changes, as text
Resulting hashes Identical to Ana's Different: the committer and the date change
Readable by eye No, it is binary Yes, it is reviewable text
Applicable on a different base No: it requires having the base commit Yes, and with -3 even with divergences
Volume Efficient for many commits One file per commit

Choose bundle when you want the exact history (same hashes, same signature, same parents). Choose format-patch when you want the change, to review it, discuss it or apply it on a different base. And there is a third route if the two machines can see each other on the network: add your colleague's repository as a remote via a filesystem path or over SSH and do a normal fetch (lesson 04-02) — Git is distributed, and any clone can be another's remote.

Solution 2

It is case 1: the kernel model, or any project organised as a tree of trees. Three independent pieces of evidence:

  1. The proportion of merges is 33% (6,127 out of 18,452). A history with a third of its commits being merges does not come out of a topic-branch workflow with squash, which produces an almost linear history. It comes out of a model in which complete trees are repeatedly integrated.

  2. The merge messages say Merge tag '...-for-6.11' of git.example.com/...-subsystem. They are integrating tags from different repositories, each one from a subsystem, all prepared for the same version. It is literally the result of a series of git request-pull followed by git merge of independent trees.

  3. Author and committer differ systematically, and the committers are a small, repeated set (Bruno, Ana) while the authors are varied (Carla, Diego). That is the fingerprint of git am: there are people who write patches and different people who apply them. In a platform pull request workflow with squash, the committer is usually the server itself or the author.

A fourth, reinforcing piece of evidence: the messages of the ordinary commits carry a subsystem prefix (net:, docs:), the usual convention when the tree is organised by area.

Solution 3

A. Small product team (case 4).

Short topic branches on main, a proposal with one review, CI that runs the tests, a protected main, integration with squash or rebase, deployment from main. That is GitHub Flow (07-04) or even Trunk Based (07-05) if they have good test coverage and use feature flags.

I would not add: Git Flow with develop and release branches (there are no multiple live versions), CODEOWNERS (five people who sit together), selective CI, or any scaling technique. 40 MB is not a performance problem: it is a small repository.

B. Medium open project (case 2).

Fork and proposal, an explicit CONTRIBUTING.md, proposal and issue templates, labels for triage and for attracting newcomers, mandatory and fast CI, integration with squash, releases with an annotated tag and SemVer.

Since "a broken version is a serious problem", I would add one maintenance branch per live major version (1.x, 2.x) onto which fixes are taken with cherry-pick (05-03), and I would publish release candidates before a major version. That is the only point where I borrow a piece of Git Flow, and for a specific reason.

I would not add: an email workflow (the barrier to entry would sink the occasional contributions, which are valuable here), or develop (a stable main plus maintenance branches is enough), or a maintainer hierarchy (with two people there is no hierarchy worth the name).

C. A clear monorepo candidate (case 3).

The symptom they describe — weeks to propagate a change in a shared library to twelve consumers — is exactly the problem the monorepo eliminates at the root: an atomic commit updates the library and all its consumers at once.

Before deciding, I would check three things:

  • That there is no code with confidentiality requirements that would rule out total visibility.
  • That they are willing to invest in tooling: selective CI and a build system with a dependency graph. Without that, the monorepo turns into a two-hour pipeline per change.
  • That the resulting volume is manageable, or that they accept the techniques of lesson 10-04.

As an intermediate and reversible step, I would start by merging only the four shared libraries into a single repository with CODEOWNERS by directory, measure the effect, and consolidate the products afterwards if the experience is good.

I would not add to begin with: submodules (06-05) to tie the libraries to the products. They would solve traceability but not the real problem, which is coordination: they would still need twelve pointer updates, one per product.

Conclusion

Four projects, four different answers to the same five questions.

  • The Linux kernel works with patches by email: git format-patch turns commits into emails, git send-email sends them to public lists, git am applies them preserving the original author, and git request-pull asks a higher level to integrate an entire tree. On top of that stands a maintainer hierarchy based on trust, with Signed-off-by as the chain of custody. The price of admission is high: every commit must stand on its own and every message must read without context.
  • A medium open project uses fork and proposal, and devotes its whole design to protecting the scarce resource, which is the maintainers' time: CONTRIBUTING.md, templates, labels and automation of the repetitive parts. The resulting history is linear and readable.
  • A company with a monorepo chooses the single repository for the atomic change across projects and global refactoring, and pays for that benefit with in-house tooling, code ownership by directory and selective CI computed from git diff --name-only origin/main...HEAD.
  • A small product team — ours — runs on short branches, proposals and a protected main, and its greatest virtue is everything it does not have.

And the cross-cutting lesson, which is the only one worth memorising:

A workflow is chosen on the basis of four constraints — trust between contributors, the cost of a mistake in production, the number of live versions and the size of the repository — and not on the basis of the prestige of whoever uses it.

The process of an enormous organisation is the scar left by the problems that organisation had. If you do not have those problems, copying it means adopting the cost without the benefit.

What is coming

In all four cases something appeared that is not Git: mailing lists, proposal platforms, build systems, patch-tracking tools, integration pipelines. Git is almost never used on its own. It lives surrounded by editors that show differences in colour, by graphical clients, by ticketing systems that want to know which commit closed issue GT-231, by linters that run before every commit and by platforms that turn a history into a conversation.

That integration layer completely changes the daily experience and, badly understood, it is also where most people lose control of what they are doing: an editor's "Sync" button hides at least two commands, and not always the ones you would expect.

In lesson 10-02: Integrating Git with Other Tools we will look at what each piece of the ecosystem genuinely contributes, what is worth carrying on doing in the terminal and why the underlying advice is always the same: automate the repetitive, but always understand which command is underneath.

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