Skip to main content
RunBook Academy

← All labs in Git, CI/CD & GitOps

Lab · intermediate · ~60 min

Lab 4: Rebase a feature branch safely and identify the failure if force-pushed

C · SimulationB · Nested virtualisation

Objectives

  • Rebase a feature branch onto the tip of main and observe that every commit on the branch is rewritten
  • Identify what rebase preserves (textual changes, file content) and what it discards (original OIDs, original committer timestamps)
  • Compare `git push --force` with `git push --force-with-lease` and explain why the latter is the safe default
  • Diagnose the symptom of a shared branch that has already been force-pushed by a teammate
  • Use the reflog to recover from an unwanted rebase
  • State the rule: never rebase a branch that another clone has already pulled

Prerequisites

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-lab is 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-rbac in the developer clone shows the two F1/F2 commits with new OIDs compared to oid-log-before.txt. The textual content is preserved; the OIDs are different.
  • git log --format='%H' main in the developer clone matches the pre-rebase state. The rebase did not touch main.
  • git ls-remote origin feature/add-rbac returns the same OID as the developer’s local feature/add-rbac after the --force-with-lease push.
  • In the teammate clone, git status after git fetch reports “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-rbac in the teammate clone refuses with a non-fast-forward error.
  • git reflog show feature/add-rbac in 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, and recovered-commit.txt exist and are non-empty.

Expected Outcome

A working directory that proves the four points the lab set out to prove:

  1. Rebase rewrites OIDs. Every commit on the rebased branch has a new hash, even when the textual change is identical.
  2. main is 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.
  3. --force-with-lease is the safe force-push. The push succeeded because the remote had not moved since the developer last fetched.
  4. 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 --hard against 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.
  • main is 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-lease is 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 pull after a teammate’s rebase refuses to fast-forward. The fix is git pull --rebase to replay your local commits on top of the new tip, or git reset --hard if 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 --rebase on the other side.

Deliverables

  • · oid-log-before.txt — every OID on `feature` and `main` before the rebase
  • · oid-log-after.txt — every OID on `feature` and `main` after the rebase, with a one-line diff explaining what changed
  • · rebase-result.txt — the final `git log --graph --oneline --decorate --all` output
  • · recovered-commit.txt — the output of `git reflog` and the OID recovered from it after an unwanted rebase

Verification status

Last reviewed
2026-08-25
Executed end to end
not yet run on hardware

The commands and configuration here have been reviewed against the verified software versions, but nobody has run this lab start to finish on a system meeting its prerequisites. Treat the Expected Outcome as the intended result rather than an observed one, and keep the Cleanup section to hand.