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.
# 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.
# 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
origin is a line in a config file“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.
$ 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
# 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)
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.
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.
The right answer depends on one question: how do you ship?
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.
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.
# 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
# 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
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 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.
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.
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.
# 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
# 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.
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.
Our services on SKE, internal tooling, most ML pipelines.
Airborne software, firmware, delivered SDKs and libraries.
release/* only when someone needs an old version fixeddevelop — it's the branch that earns leastFive settings and four habits. Nothing here is controversial, and together they remove most of the Git friction people put up with daily.
# 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"
rebase -i before review, every timegit lg, git log main..HEAD, git merge-base — thirty seconds of looking beats an hour of untanglinggit 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.
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?
Slides + examples: expert.kaikas.net/talks
Max Riechelmann · Dojo