Merge and rebase integrate work with different consequences for commit identity and ancestry. A merge normally retains the existing commits and joins their histories; a rebase transplants selected changes onto another base. The choice is not only how a graph looks. It affects review references, conflict handling, signatures, and collaborators who already depend on the old commits. Decide using the repository’s workflow and the actual branch relationships.

What a merge records

When two tips have diverged, an ordinary two-branch merge creates a commit with both tips as parents and a resulting tree. Existing commits keep their identities. The graph preserves their ancestry, not a total chronological ordering of every activity. Commit dates and display order are not substitutes for parent relationships.

If the current tip is an ancestor of the incoming tip, the merge may instead fast-forward the branch without creating a merge commit. Requiring a merge commit is a separate policy expressed by options such as --no-ff. Conversely, keeping both parents does not guarantee that the result retains both branches’ intended behavior. Content review and integration tests remain necessary.

What a rebase changes

An ordinary rebase identifies the selected commits and reapplies their changes on the new base. A recreated commit with a different parent has a different object ID, even if its patch looks the same. Original author information is normally retained, but the new commit object needs its own verification and any required signature. Links to old commit IDs do not automatically become links to the new ones.

Not every rebase rewrites every commit: an already current branch can require no replay, and equivalent upstream changes or empty results can affect which commits remain. Standard rebasing linearizes the selected work by omitting merge commits from the replay. The explicit --rebase-merges mode attempts to recreate branching structure, so “rebase always produces a straight line” is also too broad.

A merge reconciles branch tips, whereas rebase usually processes individual commits in sequence. Conflict locations and frequency can therefore differ, and a resolution may be needed more than once. Compare the final tree and the replayed changes, then rerun relevant tests; similar-looking patches do not establish identical behavior on a changed base.

When it breaks

Rewriting commits that other work depends on creates coordination costs. A private unpublished branch is usually simpler to rebase than a shared integration branch. An explicitly agreed shared-history rewrite is possible, but requires identifying affected references and consumers. A general preference for linear history is not authorization to replace someone else’s published work.

Rebase does not inherently require a forced push. A new remote branch can be created normally, and a remote tip that remains an ancestor can accept a fast-forward. Replacing a remote tip whose commits were rewritten is the non-fast-forward case. Even a lease is only a concurrency check against an expected remote value, not approval for the replacement or proof that all collaborators have been considered.

During a rebase conflict, the merge backend’s “ours” side is the rebased history so far, including the upstream base; “theirs” is the commit being replayed. Those labels can surprise someone thinking only in terms of their original feature branch. Inspect the actual versions and intended contract before resolving. Stage the specific files and continue; skipping discards the current patch rather than fixing it.

If the operation should be abandoned, git rebase --abort restores its original branch state. Starting clean makes that recovery easier to reason about. Do not treat abort as a general backup for unrelated edits made during the operation, or use forceful cleanup as routine conflict handling.

Check it quickly

Run this with Python 3 and Git 2.28 or newer. It uses a temporary repository, example identity, disabled hooks and signing, and no remote. Inherited Git routing and personal configuration are excluded. Two branches start from the same topic commit and integrate one independent upstream change using different operations.

import os
import subprocess
import tempfile
from pathlib import Path

env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}
env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull)
with tempfile.TemporaryDirectory(prefix="git-merge-rebase-") as directory:
    root = Path(directory)
    def git(*args, check=True):
        return subprocess.run(
            ["git", "-c", "user.name=History Example",
             "-c", "user.email=history@example.invalid",
             "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", *args],
            cwd=root, env=env, text=True, capture_output=True, check=check,
        )
    git("init", "-q", "-b", "main")
    git("commit", "--allow-empty", "-qm", "base")
    git("switch", "-qc", "topic")
    (root / "topic.txt").write_text("feature\n")
    git("add", "topic.txt")
    git("commit", "-qm", "feature")
    original = git("rev-parse", "HEAD").stdout.strip()
    git("branch", "merge-case")
    git("branch", "rebase-case")
    git("switch", "-q", "main")
    (root / "main.txt").write_text("upstream\n")
    git("add", "main.txt")
    git("commit", "-qm", "upstream")
    upstream = git("rev-parse", "HEAD").stdout.strip()
    git("switch", "-q", "merge-case")
    git("merge", "--no-edit", "main")
    merged = git("rev-parse", "HEAD").stdout.strip()
    print("merge-keeps-two-parents:", git("rev-parse", "HEAD^1").stdout.strip() == original
          and git("rev-parse", "HEAD^2").stdout.strip() == upstream)
    git("switch", "-q", "rebase-case")
    git("rebase", "main")
    rebased = git("rev-parse", "HEAD").stdout.strip()
    print("rebase-changes-commit:", rebased != original
          and git("rev-parse", "HEAD^").stdout.strip() == upstream)
    print("same-result-tree:", git("rev-parse", f"{merged}^{{tree}}").stdout
          == git("rev-parse", f"{rebased}^{{tree}}").stdout)
    print("original-topic-retained:", git("rev-parse", "topic").stdout.strip() == original
          and git("merge-base", "--is-ancestor", original, rebased, check=False).returncode == 1)
    git("switch", "-qc", "conflict", "topic")
    (root / "main.txt").write_text("topic alternative\n")
    git("add", "main.txt")
    git("commit", "-qm", "alternative upstream file")
    before = git("rev-parse", "HEAD").stdout.strip()
    stopped = git("rebase", "main", check=False)
    print("rebase-conflict-stops:", stopped.returncode != 0 and bool(git("ls-files", "-u").stdout))
    git("rebase", "--abort")
    print("abort-restores-branch:", git("rev-parse", "HEAD").stdout.strip() == before
          and git("symbolic-ref", "--short", "HEAD").stdout.strip() == "conflict"
          and (root / "main.txt").read_text() == "topic alternative\n"
          and not git("status", "--porcelain").stdout)

All six checks should print True. The merge has two expected parents; the rebase creates a new topic commit on the upstream tip. Their result trees match in this deliberately nonconflicting fixture, while the original topic commit remains named separately. A later add/add conflict stops rebase, and abort restores that branch’s exact tip, file contents, and clean status. No remote update is exercised or implied.

Sources

  • Git rebase reference, replay selection, merge recreation, conflict sides, and abort behavior, checked 2026-09-07.
  • Git merge reference, parent relationships, fast-forwards, and resulting trees, checked 2026-09-07.