The word is about, there's something evolving
Whatever may come, the world keeps revolving
They say the next big thing is here
That the revolution's near
But to me, it seems quite clear
That it's all just a little bit of history repeatingPropellerheads feat: Miss Shirley Bassey - History Repeating
This is an experiment to understand git rebase in the trunk based development (TBD).
TBD:
- Branch.
- Do 3 - 10 commits.
- Meanwhile trunk moves.
- Rebase.
I will indicate the git state with a complete list of references by running
make show-refsSee Makefile.
Showing more figures would be great, but Mermaid with GitGraph is shaky on Firefox. Android does not render any Github Mermaid. See Git MERGE and REBASE: The Definitive Guide. for amazing visual explanation.
This repo is an actual demo that does a real rebase. It supplements that visual explanation with precise sequence of git commands. It also explores a bit more the space of git actions.
The theme of git-based workflows is a deep forest just like Haskell monads, the CAP theorem, Js metaframeworks... Reddit, Hacker News are full of discussions proposing to ban git rebase or use it even more.
The branch will have only one commit for the sake of simplicity, but the idea of rebase is to turn
A ── C (origin/main)
\
B1 ── B2 ── B3 (feat, HEAD)into
A ── C (origin/main)
\
B1' ── B2' ── B3' (feat, HEAD)This means rewriting history, matching the B-line to the updated trunk (A-C).
git clone https://github.com/aabbtree77/git-rebase-lab.git
cd ~/git-rebase-labHEAD → refs/heads/main
refs/heads/main → 369da27 (Initial commit)
refs/remotes/origin/HEAD → 369da27 (Initial commit)
refs/remotes/origin/main → 369da27 (Initial commit)In order to avoid
"remote: Invalid username or token. Password authentication is not supported for Git operations."
set up Github tokens and run:
git remote rm origin
git remote add origin https://aabbtree77:$GITHUB_ACCESS_TOKEN@github.com/aabbtree77/git-rebase-lab.git
git remote show originHEAD → refs/heads/main
refs/heads/main → 369da27 (Initial commit)The initial commit comes from github and is no commit:
git statusOn branch main
nothing to commit, working tree cleanecho A > file.txt
git add file.txt
git commit -m "A"HEAD → refs/heads/main
refs/heads/main → 55d14ba (A)git push origin mainHEAD → refs/heads/main
refs/heads/main → 55d14ba (A)
refs/remotes/origin/main → 55d14ba (A)git checkout -b featHEAD → refs/heads/feat
refs/heads/feat → 55d14ba (A)
refs/heads/main → 55d14ba (A)
refs/remotes/origin/main → 55d14ba (A)echo B >> file.txt
git commit -am "B"HEAD → refs/heads/feat
refs/heads/feat → aaba28c (B)
refs/heads/main → 55d14ba (A)
refs/remotes/origin/main → 55d14ba (A)Open a new terminal.
git clone https://github.com/aabbtree77/git-rebase-lab.git dev2
cd dev2
cp ~/git-rebase-lab/Makefile ./HEAD → refs/heads/main
refs/heads/main → 55d14ba (A)
refs/remotes/origin/HEAD → 55d14ba (A)
refs/remotes/origin/main → 55d14ba (A)dev2 is behind local feat branch because B was never pushed to main.
In dev2:
echo C >> file.txt
git commit -am "C"HEAD → refs/heads/main
refs/heads/main → 02bf132 (C)
refs/remotes/origin/HEAD → 55d14ba (A)
refs/remotes/origin/main → 55d14ba (A)Again, before pushing, in order to avoid
"remote: Invalid username or token. Password authentication is not supported for Git operations."
from dev2 run:
git remote rm origin
git remote add origin https://aabbtree77:$GITHUB_ACCESS_TOKEN@github.com/aabbtree77/git-rebase-lab.git
git remote show origingit push origin mainHEAD → refs/heads/main
refs/heads/main → 02bf132 (C)
refs/remotes/origin/main → 02bf132 (C)dev2 is done, it updated the remote. Remote main now points to commit C.
Switch back the previous tab or cd ~/git-rebase-lab
HEAD → refs/heads/feat
refs/heads/feat → aaba28c (B)
refs/heads/main → 55d14ba (A)
refs/remotes/origin/main → 55d14ba (A)origin/main still points to A locally on dev1.
Even though remote main is now C.
git fetch originHEAD → refs/heads/feat
refs/heads/feat → aaba28c (B)
refs/heads/main → 55d14ba (A)
refs/remotes/origin/main → 02bf132 (C)This is the first real divergence. Local (dev1) main is still stuck at A. Nothing merged. Nothing rebased. Just remote-tracking ref moved.
With rebase, we want to rewrite local branch feat. Rebase is a local history rewrite. It moves local branch pointer. Remote is not touched. dev2 is not touched.
git rebase origin/mainAuto-merging file.txt
CONFLICT (content): Merge conflict in file.txt
error: could not apply aaba28c... B
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
hint: You can instead skip this commit: run "git rebase --skip".
hint: To abort and get back to the state before "git rebase", run "git rebase --abort".
Could not apply aaba28c... Bfatal: ref HEAD is not a symbolic ref
HEAD →
refs/heads/feat → aaba28c (B)
refs/heads/main → 55d14ba (A)
refs/remotes/origin/main → 02bf132 (C)This is not an error. This is the interesting part. Merge conflict.
git statusinteractive rebase in progress; onto 02bf132
Last command done (1 command done):
pick aaba28c B
No commands remaining.
You are currently rebasing branch 'feat' on '02bf132'.
(fix conflicts and then run "git rebase --continue")
(use "git rebase --skip" to skip this patch)
(use "git rebase --abort" to check out the original branch)
Unmerged paths:
(use "git restore --staged <file>..." to unstage)
(use "git add <file>..." to mark resolution)
both modified: file.txt
...rebase acts where HEAD is pointing and in the end of Stage 7 it is
HEAD → refs/heads/feat
rebase then rewrites feat:
-
constructs the set of commits
git rev-list origin/main..featwhich are the commits reachable from feat excluding commits reachable from origin/main. This is a single commit B in our case. -
detaches HEAD at C,
-
replays that unique set (B) onto C, oldest commits in the set first.
Replaying B onto C is a 3-way merge of trees (snapshots, not branches):
-
base = parent(B) = A,
-
ours = current HEAD = C,
-
theirs = B,
followed by creation of a new single-parent commit, call it D.
Here “ours“ and “theirs“ are position variables inside 3-way merge call, not some branch indicators.
In Git’s merge machinery:
“ours” = the tree currently checked out,
“theirs” = the tree being merged in,
During rebase:
You are effectively “on” C.
Git says:
“Take the changes introduced by B relative to A and integrate them into what is currently checked out.”
Currently checked out = C.
So C becomes “ours”.
B becomes “theirs”.
This new commit D (B rewritten):
-
Has parent = C.
-
Has same commit message "B".
-
Has new hash.
-
Has new timestamp.
Old B still exists in the object database, but is no longer referenced. Eventually garbage collected.
-
Git moves refs/heads/feat → D.
-
HEAD reattaches to feat.
It stops at "replays B onto C".
C is what dev2 did: echo C >> file.txt:
dev2:
cat file.txt
A
Cdev1 did echo B >> file.txt:
dev1:
cat file.txt
A
BThe 3-way tree merge leads to
CONFLICT (content): Merge conflict in file.txtAfter git rebase origin/main,
dev1:
cat file.txt
A
<<<<<<< HEAD
C
=======
B
>>>>>>> aaba28c (B)Let dev1 accept what dev2 did, and then append "B" to file.txt. This models conflict resolution with content modification by dev1 in its files.
Modify file.txt:
dev1:
cat file.txt
A
C
Bgit add .
git rebase --continueIt will show the prompt in nano with B commit's message. The best is to leave it as it is. We are not creating a new B, we are recreating it with a rewritten feat branch history.
ctrl+O ~/git-rebase-lab/.git/COMMIT_EDITMSG ctrl+X
[detached HEAD 4e45f17] B
1 file changed, 1 insertion(+)
Successfully rebased and updated refs/heads/feat.The references now:
HEAD → refs/heads/feat
refs/heads/feat → 4e45f17 (B)
refs/heads/main → 55d14ba (A)
refs/remotes/origin/main → 02bf132 (C)Old commit aaba28c is no longer referenced.
New commit 4e45f17 exists.
New commit parent = C.
If multiple commits are present in feat, this does not end, but moves from B' to B''... with resolving, git add ., and git rebase --continue, one commit at a time. Conflict markers appear only in files touched by the current commit being replayed.
If something breaks mid-rebase:
git rebase --abort
returns you to exact pre-rebase state.
Not really. We have rebased, but now we need to submit our work.
git push --force-with-lease origin feat--force-with-lease overwrites the remote branch only if it hasn’t changed since you last fetched. Safer than plain --force.
HEAD → refs/heads/feat
refs/heads/feat → 4e45f17 (B)
refs/heads/main → 55d14ba (A)
refs/remotes/origin/feat → 4e45f17 (B)
refs/remotes/origin/main → 02bf132 (C)Summary:
-
HEAD → refs/heads/feat: Your current checkout cursor; all operations affect this branch.
-
refs/heads/feat → commit 4e45f17 (B): Local feature branch, rebased on top of C.
-
refs/remotes/origin/feat → 4e45f17 (B): Remote tracking branch for feat — now synchronized via force push.
-
refs/heads/main → 55d14ba (A): Local main — hasn’t been updated yet.
-
refs/remotes/origin/main → 02bf132 (C): Remote main — trunk, includes commit C that your feature was rebased onto.
Rebased branch is on the remote.
Go to GitHub. Open a PR/MR:
Base: main
Compare: feat
Others (or just me) can now review your clean, rebased changes.
After your PR is merged into main (created PR and self accepted it):
git checkout main
git pull origin mainNow local main is up-to-date with the trunk.
Finally, update README.md and make the release for the public:
git add .
git commit -m "Final Makefile and README"
git push origin mainHEAD → refs/heads/main
refs/heads/feat → 4e45f17 (B)
refs/heads/main → 037bd00 (Final Makefile and README)
refs/remotes/origin/feat → 4e45f17 (B)
refs/remotes/origin/main → 037bd00 (Final Makefile and README)Suppose
A ── C (origin/main)
\
D1 ── D2 ── D3 (feat, HEAD)refs/heads/feat → D3
refs/remotes/origin/main → C
refs/heads/main → A (or maybe C if pulled later)
HEAD → featgit push origin mainWhat happens?
Git tries to update the remote main branch to your local main.
If local main has not moved beyond remote, this is a fast-forward.
In our current example:
local main → A origin/main → C
Local main is behind origin/main.
Git will reject push by default, because a non-fast-forward push is dangerous.
! [rejected] main -> main (non-fast-forward)Unless you use:
git push --force origin mainForce push will overwrite origin/main to point to your local main.
This is dangerous if other people depend on that remote branch.
git push origin featRemote origin/feat still points to old B chain.
Your local feat has rewritten commits D1 → D2 → D3.
Git sees that remote history diverged.
Push will be rejected unless you force:
git push --force origin feat
This is normal after rebase.
Key points:
-
Rewriting history changes commit hashes.
-
Remote still has old commits.
-
Force push is necessary to synchronize.
Update local remote refs:
git fetch originRebase local feat onto main (latest trunk):
git rebase origin/mainResolve conflicts, continue:
git rebase --continueForce push feature branch:
git push --force-with-lease origin feat--force-with-lease ensures you don’t overwrite someone else’s work accidentally.
A ── C ── 4e45f17 (origin/feat)
\
... (other trunk commits)Everyone sees your rebased commit as if it was created on top of the latest main — no messy merge commits.
Old commit aaba28c disappears from active refs, only exists in history temporarily.
Open PR (if using GitHub, GitLab, etc.), others review, merge.
Remote now sees the rewritten commits.
Update your local trunk:
git checkout main
git pull origin main-
Keep branches small.
-
Rebase frequently.
-
Use IDE merge tools, e.g. VSCode merge editor.
-
Do not heroically resolve 50-commit rebases from two weeks ago.
-
Never rebase a branch that others are actively working on (unless coordinated).
-
For trunk-based development, feature branches are short-lived; force push is safe.
-
For shared long-lived branches, you generally avoid force pushes.
The Modern Coder. Git MERGE and REBASE: The Definitive Guide
ByteByteGo. How Git Works: Explained in 4 Minutes
rebase.c is a thin orchestration layer:
revision walker decides commit set
merge-base engine decides LCA
merge backend handles 3-way tree merging
sequencer drives commit replay state machine
refs system moves pointers
It’s a choreography of subsystems.
The math is there. It’s just decentralized.
If you want to see the real cathedral
Look at these files:
commit-reach.c → merge base algorithms
revision.c → graph walking
merge-ort.c → modern merge engine
sequencer.c → replay logic
Start in:
sequencer_make_script()
Then:
pick_commits()
Then:
do_pick_commit()
That’s where a commit is actually replayed.