Skip to content

Three-way merge

import { threeWayMerge } from 'git-edge';
const { commitOid } = await threeWayMerge(repo, sourceRef, targetRef, opts?);

isomorphic-git’s own git.merge needs a worktree — a real (or hydrated-to-/tmp) checkout to run its merge driver against. For a bare repo living entirely in object storage with no durable disk, that means materializing the whole tree locally before merging anything. threeWayMerge never does this: it reads trees, blobs, and commits directly off the object graph and writes the result the same way.

  1. Identical refs. If sourceRef and targetRef already resolve to the same oid, returns immediately — no writes.

  2. Fast-forward. If sourceRef is a descendant of targetRef, targetRef is simply moved to sourceRef’s oid. No merge commit.

  3. Reverse fast-forward. If targetRef is a descendant of sourceRef (target is already ahead), same shortcut, moving targetRef to sourceRef.

  4. True three-way merge. Otherwise: finds the merge base via git.findMergeBase, flattens all three trees (base, source, target) deep and recursively into flat path → oid maps, then walks every path that exists in any of the three:

    • Added on one side only → take it.
    • Deleted on one side, unchanged on the other → the deletion wins.
    • Changed on one side only → take the changed side.
    • Unchanged on both → keep as-is.
    • Changed on both sides → falls through to content merge (below).
  5. Writes the resulting flat map back out as a real tree (building intermediate subdirectory trees as needed), creates a merge commit with both refs as parents, and moves targetRef to it.

For paths both sides genuinely changed, threeWayMerge first checks for degenerate cases — source identical to base, target identical to base, or source identical to target — and takes the non-base side directly, no line merge needed. Only a path where all three blobs actually differ falls through to a from-scratch line-level three-way merge: base/ours/theirs lines are walked in lockstep, and each line is taken automatically when only one side diverged from base. A line both sides changed differently is wrapped in conflict markers:

<<<<<<< ours
line from source
=======
line from target
>>>>>>> theirs

If any path ends up with conflict markers, threeWayMerge throws GitMergeConflictError — but only after every path has been processed and every conflicted blob has already been written to the object store (with markers included). The conflictingPaths: string[] on the error tells you which paths need human resolution; the written-but-unreferenced blobs mean a caller that wants to show “here’s the conflicted state, please resolve it” can look those blobs up by walking the same merge logic again, rather than losing the intermediate result when the error is thrown.

import { GitMergeConflictError } from 'git-edge';
try {
await threeWayMerge(repo, 'feature', 'main');
} catch (err) {
if (err instanceof GitMergeConflictError) {
// err.conflictingPaths: string[]
}
}

opts?: MergeOpts — all optional:

Option Default
message `Merge ${sourceRef} into ${targetRef}`
authorName "Git Edge"
authorEmail "git-edge@local"

Returns { commitOid: string } — the new merge commit’s oid, or the fast-forwarded ref’s new oid when no merge commit was needed.