You finish a student-discount feature, but your teammate has changed the main branch. Should you merge or rebase, and why does rebasing change commit IDs? While tidying your work, you discover a calculation bug, fix it, then accidentally remove the fix with a reset. These problems become manageable once you understand what Git records and how its history tools use those records.

The core idea: snapshots connected by history

A commit records a snapshot of tracked files: files Git has been told to include. It also records metadata, such as the author and message, and links to its parents, the commits immediately before it. A hash is the commit's identifying value, calculated from its contents and metadata, including those parent links.

A branch is a movable name pointing to a commit. HEAD identifies your current position, normally by referring to your active branch. Your working tree contains the files you edit; the staging area holds the changes selected for your next commit with git add.

Think of two students editing copies of the same lab notebook. Merging brings both sets of work together and records their meeting point. Rebasing copies one student's later entries after the other student's latest entry. The resulting pages may look similar, but their recorded relationships differ.

Git normally leaves existing commits unchanged. Commands that rewrite history create replacement commits and move branch names. That distinction explains both changed hashes and the possibility of recovering apparently lost work.

Merge and rebase: what changes?

Our example is a Python fee calculator. Students should receive a 10% discount, so a fee of NPR 1,000 should become NPR 900. While the feature branch gains four commits, main gains an unrelated documentation commit.

Letters below stand for commits; lines show parent relationships:

A---B                 main
 \
  C---D---E---F        student-discount

Merge combines the histories. Because both branches have advanced independently, it creates a merge commit, with two parents:

A---B-------------M   merge-demo
 \               /
  C---D---E---F        student-discount

Rebase replays the feature's changes on top of the newer main commit:

A---B---C'---D'---E'---F'  student-discount
    ^
   main

The replacement commits have different parents and therefore different hashes. Rebasing does not automatically fix mistakes inside their code.

QuestionMergeRebase
What happens to existing feature commits?Their identities remain unchangedThey are normally replaced when replayed onto a different base
What does history show?Where independent branches joinedA straight sequence of replayed changes
When is it useful?Integrating shared work while preserving branch relationshipsUpdating and cleaning up private feature work
Main cautionMany merge commits can make history harder to followRewriting shared commits can disrupt collaborators

A merge does not always create a merge commit. If the current branch has no independent changes, Git can fast-forward: move its branch name directly to the other branch's commit. git merge --no-ff requests a merge commit even when that shortcut is possible.

Hands-on: build, debug, lose, and recover a fix

Run this lab in Bash, available on Linux, macOS, or through Git Bash on Windows. It requires Git 2.28 or later and Python 3, invoked here as python3; some installations use python instead. Use a new directory, never a work or production repository.

Commands run sequentially unless stated otherwise. Hashes and some Git messages vary; outputs below show the relevant results. The example uses whole rupees. Production billing needs precise decimal arithmetic and explicit rounding rules.

1. Create the diverging histories

The identity settings apply only to this practice repository:

mkdir git-fee-lab
cd git-fee-lab
git init -b main
git config user.name "Practice Developer"
git config user.email "practice@example.com"

cat > fee.py <<'PY'
def payable(amount, student=False):
    return amount
PY
git add fee.py
git commit -m "Start fee calculator"

git switch -c student-discount
cat > fee.py <<'PY'
def payable(amount, student=False):
    discount = 0.10 if student else 0
    return round(amount * (1 - discount))

print(payable(1000, True))
assert payable(1000, True) == 900
PY
git add fee.py
git commit -m "Add student discount"

printf 'Student example: 1000 becomes 900.\n' > example.txt
git add example.txt
git commit -m "Add example output"

git switch -c creates a branch and switches to it. The assertion checks that the calculation returns 900; Python reports an error if it does not.

Now simulate a code cleanup that accidentally changes the discount rate, followed by another documentation commit:

python3 - <<'PY'
from pathlib import Path
path = Path("fee.py")
path.write_text(path.read_text().replace("0.10", "0.20"))
PY
git add fee.py
git commit -m "Refactor discount calculation"

printf 'Students receive a 10 percent discount.\n' > policy.txt
git add policy.txt
git commit -m "Document discount rules"

git switch main
printf 'Run examples with Python 3.\n' > README.md
git add README.md
git commit -m "Add project instructions"
git switch student-discount
python3 fee.py

The final command fails. Its relevant output is:

800
...
AssertionError

The feature has four commits after the original calculator. Its first commit works; somewhere afterward, the behavior changes.

2. Compare merge with rebase

Create a separate branch for the merge demonstration, keeping the feature available for rebasing:

git switch -c merge-demo main
git merge --no-edit student-discount
git show -s --format=%s

--no-edit accepts Git's prepared merge message. git show -s --format=%s prints only the latest commit's subject:

Merge branch 'student-discount' into merge-demo

Now replay the original feature onto main:

git switch student-discount
git rebase main

Expected completion message:

Successfully rebased and updated refs/heads/student-discount.

Both approaches include the documentation and feature changes. Both also retain the calculation bug: integrating history is not testing correctness.

If two branches change the same area incompatibly, Git may report a conflict requiring your decision. During rebase, edit the affected files, stage the resolution with git add, then run git rebase --continue. Use git rebase --abort to return to the pre-rebase state. A conflicted merge similarly needs resolved files staged and committed; git merge --abort cancels it. Begin these operations with a clean working tree so unrelated edits do not complicate recovery.

3. Clean up with interactive rebase

An interactive rebase lets you choose how individual commits are replayed. Our example-output commit belongs with the student-discount implementation, so combine them:

git rebase -i main

Git opens an editor containing a todo list, the ordered instructions for replaying commits. It lists the oldest commit first, unlike ordinary git log, which normally lists newest first.

Change only the second line's action from pick to fixup. Keep the real hashes Git displays; these placeholders illustrate the structure:

pick <hash-C> Add student discount
fixup <hash-D> Add example output
pick <hash-E> Refactor discount calculation
pick <hash-F> Document discount rules

Save and close the editor. The exact keys depend on your editor.

ActionEffect
pickApply the commit
rewordApply it and edit its message
editPause so you can change the commit
squashCombine it with the preceding retained commit and edit the combined message
fixupCombine it with the preceding retained commit, discarding its message
dropRemove it from the replayed history

Check the resulting sequence:

git log --reverse --format=%s main..HEAD

Here, main..HEAD selects commits reachable from your current position but not from main. Reachable means Git can find a commit by following parent links from a reference, such as a branch name.

Add student discount
Refactor discount calculation
Document discount rules

Three commits remain. We preserved the faulty calculation change as a separate commit, which will help diagnose it.

4. Find the regression with bisect

A regression is previously working behavior that stops working after a change. git bisect finds the commit where a reproducible good-to-bad transition occurred. It tests a commit near the middle of the candidate history, then narrows the search according to your result.

Our current commit is bad. HEAD~2, two parent steps earlier in this straight history, is the working student-discount commit. Start the search and run the unchanged test automatically:

git bisect start HEAD HEAD~2
git bisect run python3 fee.py

git bisect run reads the command's exit status, its numeric success or failure result. Here, Python exits with 0 when the assertion passes and 1 when it fails. A test can return 125 to tell Git to skip a revision it cannot test.

Abbreviated output:

800
...
AssertionError
<hash> is the first bad commit
...
    Refactor discount calculation

With only two candidates, this lab needs one test. Longer histories benefit more from repeatedly narrowing the search.

Bisect temporarily loads historical commits, using a detached HEAD: your position refers to a commit rather than your normal branch. Return before making the fix:

git bisect reset

In a real project, verify both endpoints and keep the test's meaning consistent across revisions. A missing dependency or an unreliable test can incorrectly label a commit bad. If necessary, keep a fixed test script outside the repository so historical checkouts cannot replace it.

5. Fix the bug, then recover an accidental reset

Restore the correct rate and commit the verified fix:

python3 - <<'PY'
from pathlib import Path
path = Path("fee.py")
path.write_text(path.read_text().replace("0.20", "0.10"))
PY
python3 fee.py
git add fee.py
git commit -m "Fix student discount"

The test prints 900 and succeeds.

Now simulate the mistake: you intend to discard an experiment but move the branch back past your fix. Run the next command only in this disposable lab. reset --hard moves the branch and resets the staging area and working tree; it discards tracked uncommitted edits too.

git status --short
git reset --hard HEAD~1
git reflog -2

The status command should print nothing before the reset. A reflog, short for reference log, records local movements of references such as HEAD:

<previous-id> HEAD@{0}: reset: moving to HEAD~1
<fix-id> HEAD@{1}: commit: Fix student discount

The fix disappeared from the branch's ordinary history, but its commit still exists. Inspect it, give it a rescue branch name, and apply its change again:

git show 'HEAD@{1}'
git branch rescue-discount 'HEAD@{1}'
git cherry-pick rescue-discount
python3 fee.py

HEAD@{1} means the preceding recorded position of HEAD in this exact sequence. git show displays the fix's change from 0.20 to 0.10. Creating a branch keeps that commit reachable. Cherry-pick applies a selected commit's change to your current branch. The final test prints:

900

In an actual incident, copy the observed commit hash and use it instead of assuming HEAD@{1} is the right entry. Those entry numbers shift as new movements occur. Reflog also helps locate commits displaced by a rebase, but it is local, is not pushed to a remote repository hosted elsewhere, and cannot reliably recover uncommitted edits erased by a hard reset.

Act promptly: reflog entries expire, and Git's cleanup can eventually remove unreferenced commits. Retention depends on configuration; reflog is not a backup.

Choose the operation for the situation

SituationPreferReason or alternative
Update a private feature branchRebaseReview a straight sequence on the current base
Integrate work others already depend onMergePreserve the existing commit identities
Remove small local cleanup commitsInteractive rebaseGroup related changes before sharing
Find when a repeatable bug appearedBisectNarrow the search using a dependable test
Recover committed work after resetReflog, then a rescue branchPreserve the recovered commit before further changes
Undo a commit on shared or deployed historyRevertCreate a new commit that reverses the earlier change

Do not rebase shared commits casually: teammates may have built work on their old identities. Follow team policy and coordinate any necessary rewrite. Ordinary pushes may reject rewritten history; do not respond with an unplanned force push. --force-with-lease checks an expected remote reference before overwriting it, but that check does not replace coordination.

Common mistakes and their fixes

  1. Starting with unfinished edits. Check git status; commit appropriate work or use git stash to temporarily store tracked local edits. Add -u to include untracked files, which Git has not been told to track.
  2. Treating rebase conflicts as a reason to skip commits. Resolve and stage the files, then continue. git rebase --skip omits the current commit's replay and may remove necessary work.
  3. Giving bisect a broken test. Confirm it passes on the known-good commit and fails on the bad one. Keep dependencies available and distinguish an untestable revision from a failed calculation.
  4. Trying another hard reset during recovery. Inspect the reflog and create a rescue branch first. Further destructive operations can discard more work.
  5. Assuming rewritten history removes an exposed password. Rotate or revoke the credential immediately. Copies may remain in other repositories, logs, or build systems; deleting a commit does not invalidate a secret.

Quick recap

  • Merge joins histories and preserves existing commits; it may fast-forward.
  • Rebase replays changes onto a new base, usually replacing their commit IDs.
  • Interactive rebase groups and edits commits before you share them.
  • Bisect locates a good-to-bad transition using a consistent test.
  • Reflog can recover displaced commits while they still exist; name them with a rescue branch promptly.

Test yourself

1. Why does rebasing onto a different base change commit hashes?

Answer: The replayed commits have different parent links. Those links contribute to their hashes, even if the file changes remain the same.

2. What does bisect need besides a bad current commit?

Answer: A verified good commit and a dependable way to classify the revisions it tests.

3. Does reset --hard immediately delete the removed commit?

Answer: Usually no: it moves the reference and resets files. Reflog may still identify the commit, provided Git has not removed it during cleanup.

Where to go next

  • Revert versus reset: practice recorded undo on shared branches versus moving local references.
  • Conflict resolution: learn to inspect competing changes and test the combined behavior.
  • Continuous integration: use automated checks on proposed changes so history cleanup does not hide broken code.