Git merge direction is easiest to misunderstand when a command succeeds without changing the branch someone intended to update. A feature branch may already contain the main branch's history while the main branch still lacks the feature commits. A reassuring message does not settle that question. Identify the receiver first, then compare the relevant branch tips before and after the operation.
This article turns that distinction into a small offline experiment. A local Branch note supplies the original warning about integrating in the wrong direction. The executable fixture uses only synthetic empty commits, so its evidence concerns history and branch references rather than application behavior. It neither contacts a remote nor opens an existing project. The goal is a precise acceptance test, not a universal branching policy.
Name the receiver before choosing the command
Write the intended result as a relationship between named branches: the receiving branch must contain the selected source history. Confirm the current branch before merging and read both tips afterward. The branch named as an argument is the source, not an instruction to update that branch wherever it happens to be.
Consider the requirement “make main include feature.” That sentence already provides a destination, a source, and an acceptance condition. “Merge the branches” does not. In the first test, the script deliberately stays on feature while asking it to receive main. Main is already an ancestor of feature in this fixture. The operation therefore leaves main where it started, even though Git completes normally.
The official merge manual describes integration into the current branch. That is the small but important semantic fact behind the test. The manual supplies the command definition; the fixture supplies observable evidence that the intended destination has not moved. Both are needed when explaining why a successful command can still fail to satisfy a person's stated goal. Official merge definition[1]
The local note also uses introductory language about copying a historical version when creating a branch. This article does not turn that shorthand into a claim about duplicating an entire directory. Nor does it preserve an old default branch name as a timeless rule. Its useful source contribution is the warning about operation context and direction. The example chooses explicit names so no inherited naming convention determines the result.
Separate equal tips from contained history
Tip equality and ancestry answer different acceptance questions. Equal tips are a strong, simple check for this fast-forward fixture; ancestry identifies whether one history already includes another. Keep both distinct from file equality, because the experiment can have identical file trees while still containing different commits and branch endpoints.
Empty commits are intentional here. They remove application files from the experiment without removing the history relationships under examination. A visual file comparison would reveal nothing useful about which branch received the new commit. By contrast, comparing saved tips exposes whether the expected reference moved. This is a teaching choice, not a recommendation to use empty commits in everyday work.
The ancestry check also needs careful error handling. The merge-base manual defines zero for a true ancestor relationship and one when it does not hold. Other nonzero values indicate an error. The script consequently requires exactly one for its constructed non-ancestor case; it does not treat every command failure as evidence of divergence. Official ancestry exit-status contract[2]
A concise review record should identify the receiver, its previous tip, the selected source tip, and the resulting receiver tip. Derive those values during the run rather than copying an object identifier from an article. The identities produced by a later execution can differ while the tested relationships remain correct. The assertion belongs to the relationship, not to an incidental identifier from one execution.
For the same reason, avoid a single catch-all status called “merged.” It conceals whether the operation was unnecessary, advanced a reference, or created an integration commit. A useful result explains the expected history shape and the observations that establish it. That distinction makes the record meaningful even to a reviewer who did not watch the terminal.
Treat fast-forward as an explicit acceptance constraint
When the receiver is already on the source history, the fixture can advance it directly to the saved source tip. Fast-forward-only mode makes that assumption explicit. The test then checks the new receiver tip and the unchanged source tip, rather than inferring either condition from the command's summary message.
The second check switches back to main before receiving feature. It requires main to equal the feature tip saved earlier and requires feature to remain unchanged. Together, those comparisons identify who moved, where it moved, and which branch was not supposed to move. They are more direct evidence for the requested direction than a screenshot or a count of changed files.
The merge manual distinguishes allowing fast-forward, requiring it, and requesting a merge commit. In particular, --ff-only refuses the operation when a fast-forward cannot be performed. This article uses explicit options rather than relying on an undisclosed personal default. The experiment also isolates inherited settings so the recorded case has a clear configuration boundary. Official fast-forward options[4]
This is not an instruction to prefer one history shape in every team. A repository's accepted integration shape is a separate decision. The narrow point is that a chosen constraint should be observable and enforceable. If the expected result is a direct advance, a silently different integration shape should not be accepted merely because it contains familiar files.
The practical review question is therefore concrete: after the operation, does the specified receiver point where the acceptance condition says it should? If not, preserve the evidence and investigate context. This article does not offer forceful commands for rewriting an existing project's history. A disposable example is a place to understand behavior, not permission to repair unrelated work blindly.
Use divergence as a negative control
A useful negative control makes an operation fail for a known reason, then verifies that the protected state remains unchanged. The third test constructs two independently advanced branches, requires fast-forward refusal, and checks both tips before separately creating an explicitly permitted merge with the expected ordered parents.
The fixture starts left and right from the same saved base. Each receives its own empty commit with a different message. While on left, the script confirms that left is not an ancestor of right and attempts a fast-forward-only merge. It captures the result, requires a nonzero refusal, and rechecks the two branch tips and the clean working tree.
Only after those checks does it request a non-fast-forward merge. The resulting commit must have the saved left tip as its first parent and the saved right tip as its second parent. Right must still identify its earlier tip. This contrast demonstrates two different accepted outcomes in a controlled fixture: refusal under one constraint, and an integration commit under another.
Do not turn that sequence into automatic fallback advice. In a real review, changing the integration constraint is a decision that needs an explicit reason. The test changes it only because the second outcome is another stated part of the experiment. It is not trying random options until an error disappears.
The evidence also has a strict limit. There are no file conflicts or function calls to validate. A clean working tree in this fixture says nothing about the compatibility of an application after refactoring. The experiment demonstrates reference and parent relationships; application correctness requires different checks against the integrated code.
Verification: run the complete offline fixture
Run the full script in a writable scratch environment and require every PASS group plus an overall zero exit. Each group follows concrete comparisons, not merely a successful Git invocation. A version mismatch, an unexpected reference change, or a missing parent relationship must stop the experiment and invalidate its completion claim.
The executed baseline was Windows, Git for Windows 2.53.0.windows.3, and GNU Bash 5.2.37(1)-release. These are recorded versions, not a statement that they are the newest releases or a recommendation to downgrade. Prerequisites are Git Bash with git, mktemp, and cygpath available, plus an existing writable scratch directory. Record runs under another toolchain separately.
- Run
git --versionandbash --version. Save the entire block asgit_merge_direction_lab_20261012.sh. In Git Bash, setTMPDIRto your own existing scratch directory using a Windows forward-slash path. Do not select an existing project's working tree as the test location. - Run
bash git_merge_direction_lab_20261012.sh. Require the first two PASS groups. They establish that receiving main while on feature does not advance main, and that receiving feature while on main advances the intended receiver without moving the source. - Require the third PASS group and the final overall PASS message. The ancestry result must be one, the fast-forward refusal must be nonzero, and the script itself must exit zero. The refusal message is expected negative evidence, but it is not sufficient without all subsequent state checks.
git --version
bash --version
mkdir -p "$HOME/merge-direction-scratch"
export TMPDIR="$(cygpath -m "$HOME/merge-direction-scratch")"
bash git_merge_direction_lab_20261012.sh
#!/usr/bin/env bash
#New offline repositories only; preserve all generated evidence.
set -euo pipefail
version=$(git --version)
test "$version" = 'git version 2.53.0.windows.3'
: "${TMPDIR:?Set TMPDIR to a writable scratch directory}"
lab=$(mktemp -d "$TMPDIR/merge-direction-XXXXXXXX")
lab=$(cygpath -m "$lab")
mkdir "$lab/empty" "$lab/repo"
for name in ${!GIT_@}; do unset "$name"; done
export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL="$lab/config"
export GIT_TERMINAL_PROMPT=0
export GIT_AUTHOR_NAME=Sandbox GIT_AUTHOR_EMAIL=sandbox@example.invalid
export GIT_COMMITTER_NAME=Sandbox GIT_COMMITTER_EMAIL=sandbox@example.invalid
touch "$lab/config"
g() { git -c core.hooksPath="$lab/empty" -c commit.gpgsign=false -c merge.gpgsign=false "$@"; }
cd "$lab/repo"
g init --quiet --template="$lab/empty" -b main
g commit --quiet --allow-empty -m base
base=$(g rev-parse HEAD)
g switch --quiet -c feature
g commit --quiet --allow-empty -m feature
feature=$(g rev-parse HEAD)
test "$(g branch --show-current)" = feature
g merge --ff-only main
test "$(g rev-parse main)" = "$base"
test "$(g rev-parse feature)" = "$feature"
printf '%s\n' 'PASS 1: merging main into feature leaves main unchanged'
g switch --quiet main
g merge --ff-only feature
test "$(g rev-parse main)" = "$feature"
test "$(g rev-parse feature)" = "$feature"
printf '%s\n' 'PASS 2: fast-forward advances only the receiving branch'
g switch --quiet -c left "$base"
g commit --quiet --allow-empty -m left
left=$(g rev-parse HEAD)
g switch --quiet -c right "$base"
g commit --quiet --allow-empty -m right
right=$(g rev-parse HEAD)
g switch --quiet left
set +e
g merge-base --is-ancestor left right
ancestor_exit=$?
g merge --ff-only right
refusal_exit=$?
set -e
test "$ancestor_exit" -eq 1
test "$refusal_exit" -ne 0
test "$(g rev-parse left)" = "$left"
test "$(g rev-parse right)" = "$right"
test -z "$(g status --porcelain)"
g merge --no-ff --no-edit -m 'combine synthetic histories' right
test "$(g rev-parse HEAD^1)" = "$left"
test "$(g rev-parse HEAD^2)" = "$right"
test "$(g rev-parse right)" = "$right"
test -z "$(g status --porcelain)"
printf '%s\n' 'PASS 3: divergence refused, then explicit merge records ordered parents'
printf 'git=%s\nancestorExit=%s\nffOnlyExit=%s\n' "$version" "$ancestor_exit" "$refusal_exit"
printf '%s\n' 'PASS all: synthetic empty commits; no remote or existing repository used'
The corrected complete script was executed in the scheduler environment and exited zero. All three groups passed. The ancestry check returned 1; the refused fast-forward returned 128. The latter is an observed value, not a promised universal exit code, so the script deliberately checks only that this refusal is nonzero.
An initial run exposed a Windows path representation issue: a shell-style directory was not a usable template path for native Git in this environment. The corrected script applies cygpath -m, and the successful rerun supplied an explicit scratch path. The displayed script is that corrected version. No result from the initial path-warning run is used as the final acceptance evidence.
Preserve evidence without enlarging the claim
A verification record should state the toolchain, the exact fixture, the observed exit statuses, and what was not tested. Preserve the complete script and its result rather than a selected success line. The experiment is useful because its claims are narrow enough to be checked, not because it covers every integration scenario.
The script creates a new repository and uses fictional, non-deliverable author labels. It clears inherited Git environment settings, supplies an empty configuration boundary, and points Git at an empty template and hooks directory. It retains the generated sandbox for inspection instead of deleting existing data. Those choices keep the experiment separate from an engineer's normal project.
No external code was downloaded for the fixture, no remote was configured, and no performance measurement was taken. There is no estimate of time saved, throughput, or success rate. Remote synchronization, concurrent operators, submodules, conflict resolution, and application compatibility are outside the recorded checks. Extending the fixture to those conditions requires new expectations and new results.
A compact checklist is enough to keep the review honest. Is the current branch the intended receiver? Was the source tip saved before the operation? Was the required outcome a no-op, a fast-forward, or an integration commit? Did the source remain unchanged? Did the negative control fail for its intended reason? Has a clean working tree been mistaken for a working application?
These questions should be written before running the experiment. Otherwise, it is too easy to redefine success around whichever output appeared. The point of an acceptance contract is to make an unexpected result recognizable, including a superficially successful operation in the wrong direction.
Source boundaries and the final decision rule
Start merge acceptance with a named receiver and finish with reference readback and the expected parent relationships. The local note supplies the direction problem, official documentation defines the relevant commands, and the offline fixture supplies execution evidence. None of those sources alone proves every possible integration outcome.
This topic is distinct from the site's existing semantic-merge article, which examines application contracts after refactoring. It is also separate from partial staging and index snapshots, which concern candidate file content, and from patch exchange, which concerns transfer formats. The general version-control governance article addresses team responsibilities. Here the reader's question is specifically which branch moved and whether a fast-forward constraint rejected divergence.
The checked references are the Git 2.53.0 merge manual, the merge-base manual, and the branch manual. All documents are pinned to the project's v2.53.0 tag; fast-forward definitions were also checked in merge-options at that tag. The branch manual supports the current-branch observation used by the script; the tested executable version remains the explicit baseline above.[3]
The practical rule is not “the branches have been merged.” It is “the specified receiving branch reached the expected history state.” Until that statement is backed by the relevant comparisons, successful output and unchanged files remain incomplete evidence. BlogPosting is the only recommended schema type; the article does not add an absent FAQ or promise any special search presentation.
Sources
[1] https://github.com/git/git/blob/v2.53.0/Documentation/git-merge.adoc [2] https://github.com/git/git/blob/v2.53.0/Documentation/git-merge-base.adoc [3] https://github.com/git/git/blob/v2.53.0/Documentation/git-branch.adoc [4] https://github.com/git/git/blob/v2.53.0/Documentation/merge-options.adoc