Objective
By the end of this lab you will have rebased a feature branch onto a moved main, watched every commit OID on the feature branch change, and known — for the same branch — what the safe push command is and what the destructive one is. You will also have diagnosed what a teammate’s already-force-pushed branch looks like from your clone, and recovered from an unwanted rebase using the reflog.
The point of this lab is not “rebase or merge?” — that is a team decision. The point is “if you rebase, this is what changes; if you force-push, this is what other people see; and if you are the other person, this is how you recover.”
Architecture
Two clones of the same repository: the “developer” clone that rebases
and pushes, and the “teammate” clone that pulls. Both clones share
the same .git/objects/ contents through a bare repository acting as
the remote.
flowchart LR
subgraph Developer["clone · developer"]
D_main[main]
D_feature[feature · old commits]
end
subgraph Remote["bare remote · origin"]
R_main[refs/heads/main]
R_feature[refs/heads/feature]
end
subgraph Teammate["clone · teammate"]
T_main[main]
T_feature[feature · teammate's commits]
end
Developer -- "git push" --> Remote
Remote -- "git fetch / pull" --> Teammate
The developer’s push of the rebased branch moves refs/heads/feature
on the remote. The teammate’s pull is the moment they discover the
rewriting has happened.
Requirements
- Git 2.55.x on Linux or macOS.
- A clean working directory. Nothing outside
$HOME/rebase-labis touched. - Standard Unix tools:
shred,find,rm,mkdir. - No network access. The “remote” is a local bare repository.
Scenario
You opened a pull request last week. While you were waiting for
review, a teammate merged four commits into main. You want to
rebase your feature branch onto the new main so the PR shows a
clean linear history. You have two options when you push the rebased
branch: --force and --force-with-lease. The first will silently
overwrite your teammate’s parallel work; the second will refuse if
the remote moved while you were rebasing. The team policy says
“—force-with-lease, always”, and you want to understand why.
The lab reproduces that decision and the recovery paths for both “safely rebased and pushed” and “force-pushed by someone else”.
Tasks
Task 1 — Build the bare remote and two clones
# check-shell-blocks: allow-invalid
LAB="$HOME/rebase-lab"
rm -rf "$LAB"
mkdir -p "$LAB"
cd "$LAB"
# Bare remote — no working tree.
git init --bare remote.git -b main
# Two clones.
git clone remote.git developer
git clone remote.git teammate
# Same identity in both clones.
for clone in developer teammate; do
( cd "$clone"
git config user.email 'ops@example.com'
git config user.name 'Ops' )
done
The bare remote exists so that pushing and fetching are non-trivial — they exercise the same code paths as a real GitHub or GitLab push, without involving a network. The two clones share an object store through the bare remote.
Task 2 — Build the shared base on main
# check-shell-blocks: allow-invalid
cd "$HOME/rebase-lab/developer"
cat > README.md <<'EOF'
# runbook infra
EOF
git add README.md
git commit -m 'BASE: initial repository'
# Three more commits on main to give the rebase something to land on.
echo 'config = { region = "eu-west-1" }' > config.tf
git add config.tf
git commit -m 'MAIN-1: pin region'
echo 'tags = { Owner = "platform" }' > tags.tf
git add tags.tf
git commit -m 'MAIN-2: add default tags'
echo 'module "vpc" { source = "./vpc" }' > vpc.tf
git add vpc.tf
git commit -m 'MAIN-3: add vpc module reference'
# Push to remote.
git push origin main
git log --oneline
main now has four commits that the feature branch does not yet
know about. When the feature branch rebases, Git will replay its
commits one at a time on top of these four.
Task 3 — Build the feature branch in the developer clone
# check-shell-blocks: allow-invalid
cd "$HOME/rebase-lab/developer"
git switch -c feature/add-rbac
# Two commits on feature that we will later rebase.
echo 'rbac = { enabled = true }' > rbac.tf
git add rbac.tf
git commit -m 'F1: add rbac module reference'
echo 'rbac = { enabled = true, default_role = "viewer" }' > rbac.tf
git add rbac.tf
git commit -m 'F2: add default role for rbac'
git push origin feature/add-rbac
# Capture the pre-rebase OIDs.
{
echo "--- main"
git log --format='%H %s' main
echo
echo "--- feature/add-rbac"
git log --format='%H %s' feature/add-rbac
} > "$HOME/rebase-lab/oid-log-before.txt"
The feature branch has two commits, F1 and F2, both with OIDs that
start with no relation to anything on main. These OIDs will all
change when the branch is rebased, and the lab captures the
before-state so you can see the change.
Task 4 — Rebase the feature branch onto the moved main
# check-shell-blocks: allow-invalid
cd "$HOME/rebase-lab/developer"
# Time passes: a teammate merged two commits into main on the remote.
cd "$HOME/rebase-lab/teammate"
echo 'audit_log = { enabled = true }' > audit.tf
git add audit.tf
git commit -m 'T1: add audit log configuration'
echo 'audit_log = { enabled = true, destination = "s3://audit-logs" }' > audit.tf
git add audit.tf
git commit -m 'T2: route audit log to s3'
git push origin main
# Back in the developer clone, fetch the new main.
cd "$HOME/rebase-lab/developer"
git fetch origin
git log --oneline origin/main
# Two new commits visible: T1 and T2.
# Rebase feature/add-rbac onto the moved main.
git switch feature/add-rbac
git rebase origin/main
git log --oneline --graph --decorate --all
git rebase origin/main replays F1 and F2 on top of T2 (the new
tip of main). The textual content of F1 and F2 is preserved, but
the OIDs are different — F1’s new commit has the same tree and
message but a different parent, and that is enough to change the
hash. Confirm this in the next task.
Task 5 — Capture the post-rebase OIDs
# check-shell-blocks: allow-invalid
cd "$HOME/rebase-lab/developer"
{
echo "--- main (unchanged from developer's perspective)"
git log --format='%H %s' main
echo
echo "--- origin/main (the remote's main, what we rebased onto)"
git log --format='%H %s' origin/main
echo
echo "--- feature/add-rbac (post-rebase)"
git log --format='%H %s' feature/add-rbac
} > "$HOME/rebase-lab/oid-log-after.txt"
# Diff the OIDs. F1 and F2 should both be different from before;
# MAIN-* and T* should be unchanged.
diff -u oid-log-before.txt oid-log-after.txt | head -60
Every commit on feature/add-rbac has a new OID. Every commit on
main (the developer’s local tracking branch) is unchanged. Every
commit on origin/main (the remote’s main) is also unchanged —
the rebase did not touch main at all. Only the feature branch was
rewritten.
Task 6 — Push safely with --force-with-lease
The branch has been rewritten. The remote still has the old OIDs.
A normal git push will be refused because the histories have
diverged. A --force push will silently overwrite the remote. A
--force-with-lease push will overwrite only if the remote has not
moved since you last fetched.
# check-shell-blocks: allow-invalid
cd "$HOME/rebase-lab/developer"
# A normal push: refused because of non-fast-forward.
git push origin feature/add-rbac
# expected: rejected — non-fast-forward
# The safe force-push: overwrites the remote's feature/add-rbac
# only because we fetched recently. The lease is on
# refs/heads/feature/add-rbac at the OID we last saw.
git push --force-with-lease origin feature/add-rbac
git ls-remote origin feature/add-rbac
# The OID returned matches the developer's local feature/add-rbac.
The lease is the answer to the “what if my teammate pushed in the
meantime?” question. With --force, Git does not check; with
--force-with-lease, Git compares the remote’s current OID against
the OID the local clone has cached for that ref, and refuses the
push if they differ.
Task 7 — Reproduce the failure: a teammate has already pulled
The teammate’s clone pulled the old feature branch before the developer rebased. The teammate then made their own commit on top. When the developer force-pushes, the teammate’s local branch is now divergent from the remote, and the next pull produces a non-fast-forward error.
# check-shell-blocks: allow-invalid
cd "$HOME/rebase-lab/teammate"
# The teammate pulled the old feature branch earlier.
git switch feature/add-rbac
# The teammate added their own commit on top of the OLD F2.
echo 'rbac = { enabled = true, default_role = "viewer", cluster_admin = false }' \
> rbac.tf
git add rbac.tf
git commit -m 'TF: explicit cluster_admin = false'
git log --oneline --decorate
# TF is on top of the old F2. No remote tracking update yet.
# The teammate now fetches and discovers the rebase.
git fetch origin
git status
# expected: "Your branch and 'origin/feature/add-rbac' have diverged"
# A pull will refuse to fast-forward. The teammate's TF commit is on
# top of the OLD F2; the remote is on top of the new F2. The histories
# do not share a tip.
git pull origin feature/add-rbac
# expected: fatal: refusing to merge unrelated histories
# OR: Not possible to fast-forward, aborting.
This is the symptom of an already-force-pushed shared branch. The
teammate’s local history is on the old tip, the remote is on the
new tip, and the only way the teammate’s TF commit survives is
either through git pull --rebase (which replays TF on top of the
new tip) or git reset --hard origin/feature/add-rbac (which
silently discards TF).
Task 8 — Recover an unwanted rebase from the reflog
The lab simulates the “I rebased and I should not have” scenario. You rebase, realise the merge would have been the right answer, and want the original OIDs back.
# check-shell-blocks: allow-invalid
cd "$HOME/rebase-lab/developer"
# Note the current tip of feature/add-rbac — this is the POST-rebase
# tip.
POST_REBASE="$(git rev-parse feature/add-rbac)"
echo "post-rebase: $POST_REBASE"
# Look in the reflog for the pre-rebase tip.
git reflog show feature/add-rbac | head -10
# The PRE-REBASE tip is the entry immediately above the "rebase
# finished" line. Capture it.
PRE_REBASE="$(git reflog show feature/add-rbac \
| grep -A1 'rebase finished' \
| tail -1 \
| awk '{print $1}')"
echo "pre-rebase: $PRE_REBASE"
# Confirm the pre-rebase tip is still in the object store.
git cat-file -t "$PRE_REBASE"
# expected: commit
# Reset the branch to the pre-rebase tip. The reflog entry survives
# the reset, so this is recoverable if you change your mind.
git reset --hard "$PRE_REBASE"
git log --oneline feature/add-rbac
# The tip is the old F2, and TF (if you carried it) is gone —
# because the pre-rebase reflog entry does not include TF's commit.
# Capture the reflog for the deliverable.
git reflog > "$HOME/rebase-lab/recovered-commit.txt"
The reflog is local: it lives in the clone where the rebase happened. A teammate who pulled the rebased branch and then realised they needed the pre-rebase history has only their own reflog to look at, and only if they had a local clone with the pre-rebase tip before they fetched.
Task 9 — Capture the deliverables
# check-shell-blocks: allow-invalid
cd "$HOME/rebase-lab"
# Deliverable 1: the pre-rebase OID log (already captured).
ls -l oid-log-before.txt
# Deliverable 2: the post-rebase OID log (already captured).
ls -l oid-log-after.txt
# Deliverable 3: the final graph for the developer clone.
(
cd developer
git log --graph --oneline --decorate --all
) > rebase-result.txt
# Deliverable 4: the reflog recovery trace (already captured).
ls -l recovered-commit.txt
Validation
git log --format='%H' feature/add-rbacin the developer clone shows the two F1/F2 commits with new OIDs compared tooid-log-before.txt. The textual content is preserved; the OIDs are different.git log --format='%H' mainin the developer clone matches the pre-rebase state. The rebase did not touchmain.git ls-remote origin feature/add-rbacreturns the same OID as the developer’s localfeature/add-rbacafter the--force-with-leasepush.- In the teammate clone,
git statusaftergit fetchreports “Your branch and ‘origin/feature/add-rbac’ have diverged” with the local TF commit visible as not on the remote. git pull origin feature/add-rbacin the teammate clone refuses with a non-fast-forward error.git reflog show feature/add-rbacin the developer clone has a “rebase finished” entry and the pre-rebase tip is recoverable from it.- The deliverables
oid-log-before.txt,oid-log-after.txt,rebase-result.txt, andrecovered-commit.txtexist and are non-empty.
Expected Outcome
A working directory that proves the four points the lab set out to prove:
- Rebase rewrites OIDs. Every commit on the rebased branch has a new hash, even when the textual change is identical.
mainis untouched by the rebase. The rebase replays the feature branch’s commits on top of a new base; it does not modify the base branch.--force-with-leaseis the safe force-push. The push succeeded because the remote had not moved since the developer last fetched.- The reflog is the recovery path for an unwanted rebase. The
pre-rebase tip is in the reflog of the clone where the rebase
happened, and
git reset --hardagainst it returns the branch to its pre-rebase state.
$HOME/rebase-lab/
├── developer/
│ ├── .git/
│ ├── README.md
│ ├── audit.tf
│ ├── config.tf
│ ├── rbac.tf
│ └── tags.tf
├── oid-log-after.txt
├── oid-log-before.txt
├── rebase-result.txt
├── recovered-commit.txt
├── remote.git/ # the bare remote
└── teammate/
├── .git/
└── ...
Troubleshooting
git push is refused with “non-fast-forward” but the lab said
the push should succeed. The lab uses --force-with-lease, not
plain push. Re-run Task 6 with --force-with-lease and the push
will succeed because the lease matches the remote’s current OID.
--force-with-lease is refused with “stale info”. The remote
moved between the developer’s fetch and the push — typically
because the teammate pushed something in the meantime. Re-run
git fetch and re-attempt the push; if the lease still does not
match, investigate who else pushed before overwriting.
The teammate’s git pull reports “refusing to merge unrelated
histories” instead of the divergence message. That is the same
failure from a different angle: Git refuses to create a merge commit
because the local branch and the remote branch have no common
ancestor. The fix is the same — git pull --rebase or
git reset --hard origin/feature/add-rbac.
git reflog show feature/add-rbac does not have a “rebase
finished” entry. The rebase did not complete. Either it stopped
on a conflict (resolve and git rebase --continue) or it was
aborted (git rebase --abort). Re-run Task 4 in full.
git reset --hard "$PRE_REBASE" returns the branch but loses
the TF commit. That is correct: the pre-rebase tip is the state
of the branch before the rebase, and TF was added by the teammate
after the rebase was already pushed to the remote. The teammate’s
TF commit is in the teammate’s clone, not the developer’s, and
the developer’s reflog cannot recover it.
Cleanup
# check-shell-blocks: allow-invalid
LAB="$HOME/rebase-lab"
# Keep the deliverables.
mv "$LAB"/oid-log-before.txt "$LAB"/oid-log-after.txt \
"$LAB"/rebase-result.txt "$LAB"/recovered-commit.txt \
"$HOME"/ 2>/dev/null
rm -rf "$LAB"
find "$HOME" -maxdepth 1 -name 'rebase-lab' -print
# expected: (no output)
If you ran the lab in an existing repository by accident, the branch and any local force-push state persist. Reset the branch to its pre-rebase state with the reflog:
# check-shell-blocks: allow-invalid
cd /path/to/that-repo
git reflog show feature/add-rbac
# Find the pre-rebase tip in the reflog and reset to it.
git reset --hard "<pre-rebase-sha>"
A reset that loses work is recoverable from the reflog within 90
days by default; after that, the unreachable objects are eligible
for git gc and become unrecoverable.
What You Learned
- Rebase rewrites OIDs. The textual content of each commit is preserved; the parent, committer timestamp, and tree reference are not. The hash is therefore new.
mainis not touched by a feature-branch rebase. The rebase replays the feature branch’s commits on top of a new base; the base itself is unchanged.--force-with-leaseis the safe force-push. It overwrites the remote only if the remote has not moved since you last fetched, which is the precise question you want answered.git pullafter a teammate’s rebase refuses to fast-forward. The fix isgit pull --rebaseto replay your local commits on top of the new tip, orgit reset --hardif you have nothing you need to keep.- The reflog is the recovery path for an unwanted rebase. The pre-rebase tip is in the reflog of the clone where the rebase happened, for 90 days by default.
- The shared-history rule. Never rebase a branch that another
clone has already pulled. Force-pushing such a branch is the
cause of the divergent-history symptom, and the only safe
recovery is
git pull --rebaseon the other side.