A merge connects histories and records a resulting project snapshot. In an ordinary merge of two diverged branches, the new commit names both prior tips as parents. Those parent links keep the earlier commits reachable, but they do not prove that the resulting files retain every intended behavior. Merging is both a graph operation and a content decision; verifying only one of those aspects leaves important mistakes undetected.

Parents preserve history, not a single timeline

If the current tip is an ancestor of the incoming tip, a fast-forward can simply move the current branch reference. No extra merge commit is needed. Once both sides have their own commits, the normal merge instead creates a result with two parents: the previous current tip first and the incoming tip second. The graph still contains both lines; it has not rewritten them into one linear sequence.

Git reconciles the tips using their merge base, or merge bases in more complicated history. Comparing each side against shared ancestry distinguishes changes from unchanged context. A clean textual result means the merge machinery found a way to combine the files under the selected strategy. It does not mean Git understood the application’s interfaces, business rules, or deployment dependencies.

Review the tree before recording the join

Start with a known, preserved working state. Uncommitted modifications can prevent merging and can complicate reconstructing the original state after failure. Do not assume an abort will recover arbitrary edits made before or during an operation. Record the actual branch tips being joined so review and subsequent checks refer to stable objects rather than moving names.

For an explicitly reviewed merge commit, --no-commit pauses before recording the result. It cannot stop a fast-forward, so combine it with --no-ff when the intended workflow requires a merge commit even for a linear case. Inspect the staged changes, compare the candidate result with both parents, and run the tests affected by either side. The example uses this pause to catch and repair a behavioral incompatibility before committing.

When it breaks

A textual conflict is not limited to two edits on the same line. Deletes, renames, binary changes, and other incompatible operations can require a decision. Use git status and unmerged index entries to identify unresolved paths. For ordinary text conflicts, markers help inspect the alternatives; their absence alone does not prove every conflict was resolved.

Resolve according to the contract of the combined program, then stage the specific paths and continue or commit. Removing markers and running git add only tells Git that a resolution was supplied. Generated files should be regenerated from reconciled authoritative inputs, not merged independently into a plausible-looking output that the next build will overwrite.

A more subtle failure is a clean merge with broken behavior. One branch can tighten a function’s accepted arguments while another introduces a caller using the old form. They touch different files and can each pass their own checks, yet the combined program fails. Integration tests must exercise relationships introduced by the combination, not merely repeat two isolated branch summaries.

Strategy choices can also deliberately discard content. -Xtheirs favors incoming conflicting hunks while retaining other nonconflicting changes; it is not a universal instruction to preserve both intentions. The distinct ours strategy keeps the current tree while still recording the other history as merged. A two-parent graph therefore cannot prove that the incoming implementation was adopted. Such choices require an explicit reason and content review.

Check it quickly

Run this with Python 3 and Git 2.28 or newer. Everything happens in a temporary repository with example identity, no remote, disabled hooks and signing, and no inherited Git routing or personal configuration. One branch changes a Python API to require a keyword argument; the other adds a positional caller. Python’s bytecode writing is disabled to keep the working tree clean.

import os
import subprocess
import sys
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-demo-") as directory:
    root = Path(directory)
    def git(*args):
        return subprocess.run(
            ["git", "-c", "user.name=Merge Example",
             "-c", "user.email=merge@example.invalid",
             "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", *args],
            cwd=root, env=env, text=True, capture_output=True, check=True,
        )
    def python(*args):
        return subprocess.run([sys.executable, "-B", *args], cwd=root,
                              text=True, capture_output=True)
    git("init", "-q", "-b", "main")
    (root / "api.py").write_text('def render(name):\n    return "Hi " + name\n')
    git("add", "api.py")
    git("commit", "-qm", "base API")
    git("switch", "-qc", "topic")
    (root / "api.py").write_text('def render(*, name):\n    return "Hi " + name\n')
    git("add", "api.py")
    git("commit", "-qm", "require a named argument")
    topic = git("rev-parse", "HEAD").stdout.strip()
    topic_test = python("-c", 'from api import render; print(render(name="Ada"))')
    print("topic-test-passed:", topic_test.returncode == 0 and topic_test.stdout == "Hi Ada\n")
    git("switch", "-q", "main")
    (root / "client.py").write_text('from api import render\nprint(render("Ada"))\n')
    git("add", "client.py")
    git("commit", "-qm", "add a caller")
    main = git("rev-parse", "HEAD").stdout.strip()
    main_test = python("client.py")
    print("main-test-passed:", main_test.returncode == 0 and main_test.stdout == "Hi Ada\n")
    git("merge", "--no-ff", "--no-commit", "topic")
    print("text-merge-clean:", not git("ls-files", "-u").stdout
          and git("rev-parse", "MERGE_HEAD").stdout.strip() == topic)
    broken = python("client.py")
    print("combined-behavior-failed:", broken.returncode != 0 and "TypeError" in broken.stderr)
    (root / "client.py").write_text('from api import render\nprint(render(name="Ada"))\n')
    fixed = python("client.py")
    assert fixed.returncode == 0 and fixed.stdout == "Hi Ada\n", fixed
    git("add", "client.py")
    git("commit", "-qm", "merge topic and adapt the caller")
    print("reviewed-merge-parents:", git("rev-parse", "HEAD^1").stdout.strip() == main
          and git("rev-parse", "HEAD^2").stdout.strip() == topic)
    print("fixed-and-clean:", not git("status", "--porcelain").stdout)

All six observations should be True. Both branch checks pass separately, and Git reports no unmerged paths, but the combined caller raises TypeError. Adapting the caller restores the intended output before the merge is recorded. The final commit has the two expected parents and a clean worktree. This verifies a real clean-merge failure and its repair, while showing why preserved ancestry is not evidence of preserved behavior.

Sources