Git · The Complete Picture

What is really inside .git, and how every command you type follows from it. From one line of text in HEAD down to the bytes on your disk — then merge, rebase, squash and the two branching models everybody argues about.

Developer Dojo Max Riechelmann
The thread

How we take the mystery apart

1 Open the folder four kinds of objects the hash is the filename unzip them live, by hand AFTER THIS YOU CAN read any object yourself 2 The three places HEAD, index, working tree what a checkout writes what add/commit/push write AFTER THIS YOU CAN say what a command touches 3 The rest of the folder refs and tags reflog, hooks, config FETCH_HEAD, ORIG_HEAD packed-refs, packfiles worktrees AFTER THIS YOU CAN explain every file in .git 4 Every command follows merge, rebase, squash cherry-pick, reset conflicts recovery AFTER THIS YOU CAN predict it before you run it 5 Which workflow GitFlow vs trunk-based what each one costs how we ship here AFTER THIS YOU CAN argue it with reasons
The concept

From HEAD to your files

HEAD main POINTER a branch NAME, written as text A BRANCH · refs/heads/main <one commit hash> POINTER 40 hex characters, and a newline A COMMIT metadata + one tree hash POINTER exactly one tree. Never two. A TREE · ONE LISTING OF ONE DIRECTORY a.txt mode · name → a blob hash docs/ mode · name → a tree hash src/ mode · name → a tree hash WALK EVERY ENTRY, RECURSIVELY THE ROOT TREE a.txt docs/ e.txt f.txt sub/ src/ b.txt c.txt d.txt HEAD / ref a label holding a hash NOT AN OBJECT commit metadata + one tree OBJECT tree one directory listing OBJECT blob zipped content, no name OBJECT file the same bytes, on disk NOT AN OBJECT
The real thing

Same shape, real hashes

THE PINNED BRANCH · DRAWN OUT IN FULL .git/HEAD ref: refs/heads/docs/dojo-git-demo POINTER .git/refs/heads/docs/dojo-git-demo a428cf99ae4d9e7b184683c9104d46ebd8bae6c5 POINTER .git/objects/a4/28cf99… COMMIT tree 903c90337851d8f6f54a0f46b605adb75d7a1519 POINTER .git/objects/90/3c9033… TREE 903c90337851d8f6f54a0f46b605adb75d7a1519 THE OTHER BRANCHES · SAME SHAPE, NOT DRAWN refs/heads/main refs/heads/docs/git-workflows-commit-graphic refs/heads/chore/repin-ci-workflows-lib-v2-11-1 Each one holds its own commit hash and hangs a complete tree under it. Move HEAD, get that one. WHAT THAT TREE HOLDS · REAL NAMES, FULL HASHES THE ROOT TREE 903c90337851d8f6f54a0f46b605adb75d7a1519 AGENTS.md a4b25de938a6b00920250e3c7231f80d32f58fbb common/ 5b9905e236a1e93147d1c96d28d15121a0813c38 palette.css a30d3d03666bc3ccffe4f3133d74b696b80a3d9f theme.css e9be73f83e3920d389a6face4493a0e48f283cdf git_workflows/ 68cf173c24b8ac66c5a88a020bd6c2767a6fd5e0 git_workflows.html 1dbdcf3756fdc9e3938b44c20b7955ca8adeba84 index.html 9c5e1c73cee19dc5a8c7617a5c418a508fd9c91a … 17 more entries EVERY ONE OF THESE HASHES IS ON THE NEXT SLIDE, LIVE The name sits in the TREE ENTRY, never in the blob — which is why two identical files share one blob, and why renaming a file writes no blob at all. HEAD / ref a label holding a hash NOT AN OBJECT commit metadata + one tree OBJECT tree one directory listing OBJECT blob zipped content, no name OBJECT file the same bytes, on disk NOT AN OBJECT
The object model · live, on this repo

Walk the same chain by hand, in a shell

Paste one block at a time · hops 1 to 3
# HEAD says which branch you are on
$ cat .git/HEAD
ref: refs/heads/docs/git-workflows-commit-graphic
# a branch NAME — and the slashes in it are real
# directories under .git/refs/heads/

# a branch pinned for this demo, so the hashes below never move
$ cat .git/refs/heads/docs/dojo-git-demo
a428cf99ae4d9e7b184683c9104d46ebd8bae6c5
# 40 hex + one newline = 41 bytes. That is a branch.

# split that hash after 2 characters — that IS the path
$ zlib-flate -uncompress < .git/objects/a4/28cf99ae4d9e7b184683c9104d46ebd8bae6c5
commit 1113 tree 903c90337851d8f6f54a0f46b605adb75d7a1519
parent f327d13e461e3388ac7531840f109bd9cf5c5246
author Max Riechelmann … committer … gpgsig … message …
# THE COMMIT. One tree hash — and not one byte of any file.
Paste one block at a time · hops 4 to 6
# the tree that commit named. Binary, so pipe it through xxd.
$ zlib-flate -uncompress < .git/objects/90/3c90337851d8f6f54a0f46b605adb75d7a1519 \
    | xxd | grep -A2 index
000002c0: e031 3030 3634 3420 696e 6465 782e 6874  .100644 index.ht
000002d0: 6d6c 009c 5e1c 73ce e19d c5a8 c761 7a5c  ml..^.s......az\
# after "index.html" comes a NUL, then TWENTY RAW BYTES:
# 9c5e1c73cee19dc5a8c761… — that is the blob hash

# and that blob, at last, is the file
$ zlib-flate -uncompress < .git/objects/9c/5e1c73cee19dc5a8c7617a5c418a508fd9c91a \
    | head -2
blob 13769 <!DOCTYPE html>
<html lang="en">

# now hash the file lying in the folder
$ git hash-object index.html
9c5e1c73cee19dc5a8c7617a5c418a508fd9c91a
# identical → that blob IS that file
And that last step is the whole "copy" No syscall magic, no kernel space. A checkout is a copy out of zipped files, and that is all it is.
git commit

A commit writes three files

COMMIT · THE ONE BEFORE f327d13e… COMMIT · HEAD a428cf99… NEW parent tree tree ROOT TREE · OLD 3d8c1d68… ROOT TREE · HEAD 903c9033… NEW REACHED FROM BOTH COMMITS tree 68cf173c… git_workflows/ blob a4b25de9… AGENTS.md … 502 more byte-identical BLOB · index.html, BEFORE 33ba967d… BLOB · index.html, NOW 9c5e1c73… NEW ONE FILE CHANGED · THREE NEW OBJECTS · NOTHING ELSE COPIED AND IT MOVES ONE REF refs/heads/main rewritten to the new commit — that is all "committing" means logs/HEAD one line appended: the reflog AND NOT ONE FILE OF YOURS. Your working tree is neither read nor written. WHAT IT COST from f327d13e: 504 objects from a428cf99: 507 objects NEW OBJECTS: 3 the commit, the root tree, and one blob
The rule behind the three One new blob per changed file, one new tree per changed directory, one commit. Everything else is the same object, reused byte for byte.
git add

Where does the filename go?

1 git add STORES THE CONTENT $ git add a.txt objects/ce/013625030ba8db… Your bytes are safely in .git now — but stored under a HASH, not under a name. 2 NOTHING SAYS IT IS a.txt a tree is what pairs a name with a blob …and the tree only gets written when you commit. So the blob is in .git, hanging off nothing at all. UNTIL YOU COMMIT, THAT PAIRING HAS TO BE WRITTEN DOWN SOMEWHERE. 3 THAT SOMEWHERE IS .git/index 100644 ce013625030ba8db… 0 a.txt the blob the name one line, and the pairing is right there in it. git commit then turns every line of this file into tree objects — and only then is the blob hung up where it belongs. GIT REWRITES THIS FILE ON EVERY git add. It is the only file in .git that ever gets rewritten — objects and refs are only ever added. AND THE WAY BACK · git rm $ git rm a.txt removes the index entry, deletes the file — but NOT the blob $ git rm --cached a.txt removes only the index entry, keeps your file SO WHO CLEARS IT? THE GARBAGE COLLECTOR The blob is unreachable now. That is not the same as gone. $ git reflog expire --expire=now --all $ git gc --prune=now → gone Left alone it lingers about two weeks by default — that grace period is your safety net. BUT IF IT WAS EVER COMMITTED, gc WILL NOT TOUCH IT.
git status

Which files did I change?

It has to answer one question: which files did I change? THE OBVIOUS WAY read every file, hash it, and compare to the index 90 files = 90 reads WHAT IT ACTUALLY DOES ask the OS for the file's size and timestamp, and compare those numbers 90 files = 90 cheap calls It only opens a file if those numbers disagree. AND THOSE NUMBERS ARE IN THE INDEX, BECAUSE git add WROTE THEM DOWN. a.txt ce013625… size 6 mtime 1786062438 the blob it will commit how the file looked when git last saw it SO WHAT IT PRINTS HAS TWO COLUMNS one per comparison, and they are read left to right: M staged the index differs from the last commit your file and the index agree M not staged your file differs from the index the last commit and the index agree M M both you staged one version, then kept editing a.txt then holds three different versions at once — one committed, one staged, one on disk. THE TWO CHARACTERS ARE THE TWO COMPARISONS.
git diff

Any two of the three

a.txt is written so its content names where it sits — then the diff output explains itself. last commit "committed" index "staged" your file on disk "editing" git diff --cached -committed +staged git diff -staged +editing git diff HEAD -committed +editing git diff HEAD~1 HEAD -older +committed any two commits work the same way — two trees, walked side by side THERE IS NO DIFF OBJECT. The types are commit, tree, blob and tag. Every diff you have ever seen was computed on the spot — which is why the same change can be shown as --stat, as --name-only, or word by word.
git push

origin is a line in a config file

1 · "origin" IS NOT A THING. IT IS THIS. $ cat .git/config [remote "origin"] url = git@github.com:Kaikas/dojo-decks.git fetch = +refs/heads/*:refs/remotes/origin/* A name for a URL, and one mapping rule. The whole config is 19 lines. 2 · AND THAT RULE IS WHERE REMOTE BRANCHES COME FROM +refs/heads/* : refs/remotes/origin/* "their branches land under this name of mine" refs/remotes/origin/main 41 bytes refs/remotes/origin/docs/git-workflows-… 41 bytes refs/remotes/origin/HEAD 30 bytes Ordinary files. origin/HEAD even holds a path, like your own HEAD does. 3 · SO WHAT DOES git push DO? It opens that URL, asks which objects the other side already has, and sends the ones it lacks. INTO THE OTHER .git the 5 objects it did not have identical hashes, byte for byte — a hash comes from content, so nothing is compared or translated its refs/heads/main moved to your commit — that is the actual "push" AND LOCALLY, ONE FILE refs/remotes/origin/main your note of where the remote stood. Nothing else. LOCAL OBJECTS: 5 → 5 A push writes no objects on your side. It copies them. A REMOTE IS JUST ANOTHER .git DIRECTORY. Same objects, same refs, same layout — reachable over a URL instead of a path. There is no server-side magic to learn.
git restore · git checkout · git reset · git switch

Which command touches which of the three?

Start state: the last commit holds "committed", the index holds "staged", your file on disk says "editing". COMMAND WHICH POINTER MOVES INDEX YOUR FILE a.txt ENDS UP AS git restore a.txt none no YES "staged" git checkout -- a.txt none no YES "staged" git restore --staged a.txt none YES no "editing" git reset a.txt none YES no "editing" git switch other-branch HEAD YES YES that branch's version git checkout other-branch HEAD YES YES that branch's version git reset --soft HEAD~1 the branch ref no no "editing" git reset --mixed HEAD~1 the branch ref YES no "editing" git reset --hard HEAD~1 the branch ref YES YES "older" THE SAME OPERATION THE SAME OPERATION THE SAME OPERATION THREE PAIRS. SIX NAMES FOR THREE OPERATIONS — THAT IS WHY PEOPLE MIX THEM UP. reset moves the branch ref. switch and checkout move HEAD. The file-level ones move nothing at all. SO WHAT DOES -- DECIDE? Nothing, when the name is unambiguous: with no ref called a.txt, both forms print "Updated 1 path from the index". Everything, when it is not. With a BRANCH a.txt and a FILE a.txt in one repo: $ git checkout a.txt Switched to branch 'a.txt' the ref wins, silently $ git checkout -- a.txt the file is restored after -- everything is a path git log a.txt REFUSES INSTEAD: fatal: ambiguous argument 'a.txt'
git switch

Measured: what it does to your disk

THE WHOLE ALGORITHM · SIX STEPS 1 read .git/HEAD → the text "ref: refs/heads/main" · 21 bytes 2 read .git/refs/heads/main → one commit hash, 05ab53d… · 41 bytes 3 read that commit → its tree → the complete list of files that SHOULD be there 4 compare that list to .git/index → the index holds path + blob hash + stat data · 209 bytes 5 write or delete ONLY the paths that differ → openat() write() renameat() unlinkat() — the only disk writes 6 rewrite .git/index and .git/HEAD → 209 + 21 bytes. Then the process exits. Four small files read. A handful of files written. That is a branch switch, start to finish. NO DAEMON · NO KERNEL MODULE · NO FILESYSTEM MAGIC MEASURED · git switch feature → main FILE IN TARGET TREE GIT DOES INODE ON DISK a.txt d21fe31 rewrite 2308122 → 2308201 content differs → Git wrote a new file and renamed it into place b.txt ea0a4a7 nothing 2308123 → 2308123 identical blob → Git never even opened this file c.txt — absent — unlinkat() file is gone not in the target tree → removed from disk COST = THE NUMBER OF DIFFERING FILES — NOT THE SIZE OF THE REPO OR ITS HISTORY 9,000 files in the repo, 3 of them different → exactly 3 writes. A changed inode is also why tail -f stops following a file after a checkout: it is holding the old inode, which no directory entry points at any more.
And why a dirty file blocks a switch Git would have to overwrite bytes it has no copy of.
the commit object

What is in a commit

WHAT IS IN A COMMIT $ zlib-flate -uncompress < .git/objects/a4/28cf99… tree 903c90337851d8f6f54a… the snapshot — exactly one, always parent f327d13e461e3388ac… the hash of the commit before this one author Max Riechelmann <…> 1785… who wrote it · unix time · timezone committer GitHub Enterprise <…> who applied it — differs after a rebase gpgsig -----BEGIN PGP SIGNATURE signs every line above it one blank line docs(landing): note the 2026-07… the message — free text to the end WHICH IS EXACTLY WHAT A DAG IS D irected every edge has one direction: child → parent, never back A cyclic no path leads back to its start — a hash would have to contain itself G raph nodes and edges, and nothing else AND THE SHAPE THAT parent PRODUCES c1 c2 c3 f1 refs/heads/main refs/heads/feature parent c2 HAS TWO CHILDREN — BECAUSE TWO COMMITS NAME IT AS THEIR PARENT. SO HOW DO YOU MAKE THAT FORK? COMMAND CHILDREN OF c2 $ git switch -c feature 0 a second ref on c2 — and no child at all $ git commit 1 the first child appears: f1, on feature $ git switch main 1 still one — switching creates nothing $ git commit 2 the second child: c3, on main CREATING A BRANCH FORKS NOTHING. COMMITTING ON IT DOES. A branch is 41 bytes naming a commit. Until you commit, both refs name the same one.
the commit object · merges

A merge is a new commit with two parents

THE CHAIN STOPS BEING A LINE c1 c2 c3 f1 m main NEW c2 has two children — a branch point. Nothing about c2 changed when they appeared. m has two parents — a merge. It is a NEW commit; c3 and f1 are untouched. A MERGE MERGES TWO (OR MORE) BRANCHES INTO A NEW COMMIT. Nothing is rewritten, nothing is moved. One node is added, with one edge per branch it joins — and the branch ref now names it. A REAL MERGE COMMIT FROM THIS REPO $ git cat-file -p 36c617aa tree 7f5cf673e00e2ee793fc… parent fae0448effea751514… parent f515856acdce81c703… still ONE TWO PARENTS, STILL EXACTLY ONE TREE. A merge resolves to a single snapshot. There is no object type that could hold two. SO HOW MANY PARENTS ARE POSSIBLE? 0 a root commit 1 an ordinary commit 2 a merge 3+ several branches at once git names the 3+ strategy "octopus", and it aborts rather than leave a conflict Children are unbounded too, but no commit knows its own: nothing points forward, so git scans the refs to find them. EXCEPT WHEN IT MAKES NO COMMIT AT ALL. If the branch you are on has not moved since the fork, git merge prints "Fast-forward" and simply moves your ref onto the other one — measured: no new commit, and the two refs end up equal. git merge --no-ff forces one.
Merge vs Rebase

Same changes · two very different histories

git merge feature/cart HISTORY GROWS SIDEWAYS · FEATURE COMMITS LAND IN BETWEEN main feature init add auth ← branch point fix logging bump deps feat: cart model feat: cart API Merge branch 'cart' ↑ new commit · two parents merge commit joins both lanes $ GIT LOG --ONELINE 3f2a1c9 Merge branch 'cart' 9c7d2ef bump deps b41e8aa feat: cart API 5ad9007 fix logging e77c1d3 feat: cart model 1b3f412 add auth 0a9e544 init FEATURE COMMITS SIT BETWEEN MAIN'S — INTERLEAVED BY DATE Hashes unchanged · nothing rewritten git rebase main HISTORY STAYS LINEAR · FEATURE COMMITS LAND ON TOP main before e77c1d3 b41e8aa abandoned originals replayed as new init add auth ← old branch point fix logging bump deps ← new base feat: cart model feat: cart API no merge commit · linear $ GIT LOG --ONELINE d81c4b7 feat: cart API 7e2b9f1 feat: cart model 9c7d2ef bump deps 5ad9007 fix logging 1b3f412 add auth 0a9e544 init FEATURE COMMITS STACK ON TOP — MAIN'S HISTORY UNTOUCHED BELOW Hashes changed · originals abandoned
The whole difference in one line Both give you the identical working tree. Merge preserves when things happened, so feature work is interleaved with everything else by date. Rebase preserves a readable story, so feature work reads as one block on top — at the price of new hashes.
conflicts

Same conflict, more rounds

THE MECHANISM IS THE SAME · A THREE-WAY MERGE $ git ls-files -u a.txt stage 1 · base stage 2 · ours stage 3 · theirs Three blobs for one path — the same three stages the index slide showed. Both commands compare exactly these, and the conflict markers in your file are written from them. Nothing about that differs. MERGE · ONE RESOLUTION $ git merge feature Auto-merging a.txt CONFLICT (content): Merge conflict in a.txt state lives in one file: .git/MERGE_HEAD You resolve once and commit. The merge commit records whatever you resolved to — one snapshot, one decision. REBASE · ONE PER REPLAYED COMMIT $ git rebase main Rebasing (1/2) CONFLICT (content): Merge conflict in a.txt error: could not apply 819371e… mine1 $ git rebase --continue CONFLICT (content): Merge conflict in a.txt error: could not apply df0e192… mine2 state lives in a directory, with a counter: .git/rebase-merge/msgnum 1 of 2, then 2 of 2 THE SAME SINGLE LINE, TWICE — ONE ROUND PER COMMIT YOU ARE REPLAYING. A rebase conflict is not a different kind of conflict. It is the same one, as often as you have commits that touch it. THE WAY OUT git merge --abort git rebase --abort --skip --continue rebase needs the extra two: it stops n times
Squash

Squash · many commits become one

wip, wip2, fix typo, actually fix typo” is an honest record of your afternoon and a useless record for your team. Squashing collapses that noise into one reviewable change. Three tools do it, and they are not interchangeable.

1 · Interactive rebase — full control
$ git rebase -i main

# Editor opens; you rewrite the plan:
pick   7e2b9f1 feat: cart model
squash d81c4b7 wip
squash 3c19a4e fix typo
pick   9f04b22 feat: cart API

# pick   = keep as is        drop = delete it
# squash = fold up + edit message
# fixup  = fold up + DISCARD message
# reword = keep code, fix message
# edit   = stop here so I can amend

# Result: 4 commits -> 2 clean ones.
$ git push --force-with-lease
2 · Autosquash — the pro move
# You spot a bug in a commit from this morning.
# Don't write "fix review comment" — target it:
$ git commit --fixup 7e2b9f1
[feature/cart a91c3d2] fixup! feat: cart model

# Repeat as often as you like, then let Git
# reorder and fold everything automatically:
$ git rebase -i --autosquash main
pick  7e2b9f1 feat: cart model
fixup a91c3d2 fixup! feat: cart model   # auto-placed

# Make it the default:
$ git config --global rebase.autosquash true

# 3 · Squash-merge — whole branch, one commit
$ git merge --squash feature/cart && git commit
# (this is GitHub's "Squash and merge" button)
Careful with squash-merge merge --squash creates a commit with one parent — Git does not record that the branch was merged. Merge the same branch again later and Git will happily replay everything. Delete the branch right after squash-merging, and never squash-merge a long-lived branch.
Branching Models

Now the part people argue about

Merge, rebase and squash are mechanics. A branching model is the policy that decides when you use them — how work enters the codebase, and how it gets to production. Two models dominate, and they disagree about almost everything.

GitFlow · 2010 vs Trunk-Based · today's default

The right answer depends on one question: how do you ship?

GitFlow

GitFlow · five branch types, strict roles

Vincent Driessen's 2010 model. main holds only releases — every commit on it is tagged and shipped. develop is the integration branch. Everything else is temporary and has exactly one job.

main production only hotfix/* release/* develop integration feature/* feature/cart · days to weeks feature/search release/1.1 · stabilise bugfix only back-merge or 1.1 fixes are lost v1.1.0 v1.0.0 hotfix/1.1.1 · cut from main merge to BOTH v1.1.1 Time → · every arrow is a merge commit · nothing is ever rebased · main is always releasable and always tagged
GitFlow · Practice

A release, start to finish

The full cycle for shipping 1.1 and then hotfixing it. Note how often the answer to “where do I branch from?” differs from “where do I merge to?” — that asymmetry is the whole model, and the place people get it wrong.

Feature → develop → release → main
# 1. Feature: branch from develop, merge to develop
$ git switch -c feature/cart develop
$ git switch develop
$ git merge --no-ff feature/cart

# 2. Release: cut from develop. Feature freeze —
#    ONLY bugfixes now; develop stays open for 1.2
$ git switch -c release/1.1 develop
$ echo "1.1.0" > VERSION && git commit -am "bump"
#   …QA finds things, you fix them HERE…

# 3. Ship: merge to main AND tag AND back-merge
$ git switch main
$ git merge --no-ff release/1.1
$ git tag -a v1.1.0 -m "Release 1.1.0"
$ git switch develop
$ git merge --no-ff release/1.1   # ← never skip
$ git push origin main develop --tags
Hotfix · production is on fire
# Production runs v1.1.0. develop already has 3 weeks
# of unreleased 1.2 work — you cannot ship from there.
# So branch from the TAG, not develop:
$ git switch -c hotfix/1.1.1 v1.1.0
$ vim src/auth/session.py
$ git commit -am "fix: session token expiry off by 1h"

# Merge to main, tag, deploy:
$ git switch main
$ git merge --no-ff hotfix/1.1.1
$ git tag -a v1.1.1 -m "Hotfix 1.1.1"

# CRITICAL: also merge into develop, or 1.2 ships
# WITHOUT the fix and the bug comes back.
$ git switch develop
$ git merge --no-ff hotfix/1.1.1
CONFLICT in src/auth/session.py   # ← the usual tax
$ git push origin main develop --tags
Where teams get burned The forgotten back-merge. A hotfix that reaches main but not develop is a bug that comes back on the next release — and it comes back looking like a regression nobody can explain. If you run GitFlow, make this a CI check, not a habit.
GitFlow

What GitFlow is actually good at

GitFlow gets dismissed a lot, usually by people whose problem it never tried to solve. It was designed for software with versioned releases and multiple supported versions in the field — and for that, it is still hard to beat.

Genuinely strong when
  • You support several versions at once. Customers on 1.1 and 2.0 both need fixes
  • Releases are events — scheduled, QA'd, signed off, certified
  • You can't deploy on demand — embedded, on-prem, regulated, airborne
  • A release branch must be stabilised while new work continues
  • Contributors are external or untrusted — long-lived branches are the isolation
DO-178C on-prem firmware
The bill comes due as
  • Merge debt. Every change crosses 2–4 branches; the same conflict gets resolved more than once
  • Long-lived branches drift. A two-week feature branch is a two-week-old integration risk
  • “Done” is far from “deployed.” Merged to develop ≠ in anyone's hands
  • CI is ambiguous. Which branch is the truth? develop? release/1.1? main?
  • Even the author now recommends against it for continuously-delivered web apps
The honest summary GitFlow's complexity is the price of supporting versions you can't force anyone to upgrade from. If you deploy one version, to your own infrastructure, whenever you like — you are paying that price for nothing.
Trunk-Based Development

Trunk-based · one branch, always shippable

One long-lived branch: main. Everyone commits to it — via short-lived branches that live hours, not weeks. Nothing is ever “in integration”, because integration happens continuously, every day, by definition.

main always green, always deployable feature/cart-api · 4 h fix/token-expiry · 40 min feature/search · 1 day (behind a flag) chore/bump-deps · 20 min deploy deploy deploy every merge can ship → optional: release/1.4 cut from main, fixes cherry-picked in — never merged back Time → · branches measured in hours · no develop · no integration phase
Trunk-Based · Practice

A day on the trunk

The mechanics are almost boring — that's the point. The interesting part is the answer to “what if the feature takes three weeks?”: you still merge every day, and you use a feature flag to keep the unfinished half dark.

The loop · repeat 1–3× per day
# Morning: start from a current trunk
$ git switch main && git pull --rebase
$ git switch -c feature/cart-api

#   …small, focused change. 2 hours, not 2 weeks…
$ git commit -am "feat: POST /cart endpoint"

# Before pushing: replay onto whatever landed
# while you worked. Conflicts stay small because
# you're never more than hours behind.
$ git fetch origin && git rebase origin/main
$ git push -u origin feature/cart-api

# PR -> review -> squash-merge -> branch deleted.
# Same day. Then delete it locally too:
$ git switch main && git pull --rebase
$ git branch -d feature/cart-api
“But my feature takes 3 weeks”
# You still merge daily. The switch, not the branch,
# is what hides the unfinished work:

# Day 1 — merge the dead code, flagged off.
# It ships to prod. Nobody can reach it.
if settings.features.new_cart:      # default: off
    return NewCartService().checkout(order)
return LegacyCartService().checkout(order)

# Days 2..14 — keep merging into the dark branch.
# CI runs BOTH paths; the flag stays off.

# Day 15 — on for 5% of traffic. Then 50%. Then all.
# A config change, not a deploy — undo in seconds.

# Day 20 — the real work: DELETE the flag and the old
# path. A flag you never remove is tech debt.
The trade you're making Trunk-based moves the hard part out of Git and into engineering discipline. There is no integration phase to catch problems, so the safety net has to be real: fast CI on every push, tests you trust, feature flags, and the team habit that a red trunk is everyone's emergency. Without those, trunk-based is just “everybody pushes to main and hopes”.
Comparison

Head to head

GitFlow
“Isolate work, integrate at the end, ship on a schedule.”
  • Branches · 5 types, weeks long
  • Integration · a phase, at the end
  • Release · cut, stabilise, tag, ship
  • Hotfix · own branch, merge to 2 places
  • Rollback · ship the previous tag
  • Needs · QA capacity, release coordination
  • Fails when · branches outlive their usefulness
  • Conflicts · rare but large and late
Trunk-based
“Integrate constantly, ship continuously, hide with flags.”
  • Branches · 1 long-lived, rest are hours
  • Integration · continuous, by definition
  • Release · any green commit on main
  • Hotfix · a normal commit, rolled forward
  • Rollback · flag off, or redeploy previous
  • Needs · fast CI, real tests, flags, discipline
  • Fails when · CI is slow or trusted less than 100%
  • Conflicts · frequent but tiny and immediate
The finding behind the hype The DORA / Accelerate research is unusually blunt here: short-lived branches and fewer than three active branches per repo predict higher delivery performance — and the effect holds even after controlling for team size and domain. Note what it measures: branch lifetime, not branch names. You can run GitFlow's structure with short branches and get most of the benefit.
Decision

Pick by how you ship, not by taste

Almost every branching-model argument is really an unstated disagreement about deployment. Answer the deployment question first and the branching model mostly falls out of it.

Deploy on demand?

  • You control the infrastructure
  • One version is live at a time
  • Rollback is a redeploy
  • CI < 15 minutes and green
→ Trunk-based

Our services on SKE, internal tooling, most ML pipelines.

Ship versions to others?

  • Several versions supported at once
  • Upgrades are the customer's choice
  • Release needs sign-off / certification
  • QA works on a frozen candidate
→ GitFlow-ish

Airborne software, firmware, delivered SDKs and libraries.

Honest middle ground

  • GitHub Flow — main + short PR branches + tags
  • Add release/* only when someone needs an old version fixed
  • Cherry-pick into release branches; never merge them back
  • Drop develop — it's the branch that earns least
what most of us want
If you take one thing Branch lifetime is the variable that matters. Whatever the boxes on your diagram are called, a branch that lives a day is cheap and a branch that lives a month will hurt — and it will hurt at the worst possible moment, which is the release.
Takeaway

Configure this today

Five settings and four habits. Nothing here is controversial, and together they remove most of the Git friction people put up with daily.

Paste this into your shell
# Never create "Merge branch 'main' of …" again
$ git config --global pull.rebase true

# Remember conflict resolutions and replay them
$ git config --global rerere.enabled true

# --fixup commits fold themselves in automatically
$ git config --global rebase.autosquash true

# Carry stashes/untracked through a rebase safely
$ git config --global rebase.autostash true

# Show the common ancestor in conflicts
$ git config --global merge.conflictstyle zdiff3

# And the alias you'll use a hundred times a day
$ git config --global alias.lg \
    "log --graph --oneline --decorate --all"

The four habits

  • Rebase private, merge public. If anyone else has pulled it, it is public — and public history is read-only
  • Keep branches under a day. This one habit does more for integration pain than any model on any slide today
  • One logical change per commit. Not one PR, not one afternoon. rebase -i before review, every time
  • Read before you resolve. git lg, git log main..HEAD, git merge-base — thirty seconds of looking beats an hour of untangling
--force-with-lease --no-ff --abort reflog
Scratch repo, five minutes Everything in this deck was built in a throwaway repo — and that is the fastest way to actually learn it. git init /tmp/gitlab && cd $_, make a handful of commits, and try to break it. You can't: reflog has your back, and nothing you do there matters.

Questions · and war stories

Merge or rebase, GitFlow or trunk — the mechanics are the easy half. What does your repo actually do today, and what does it cost you?

merge adds rebase copies squash collapses short branches win

Slides + examples: expert.kaikas.net/talks