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. Already up to date. If targetRef already contains sourceRef (the same commit, or source is an ancestor of target), returns target’s oid — nothing is written and no ref moves.

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

  3. True three-way merge. Otherwise: finds the merge base via git.findMergeBase (unrelated histories throw GitEdgeError) and merges the three root trees recursively. At each level, per entry name:

    • Both sides identical (oid and mode) → take it (a deletion on both sides stays deleted).
    • Only one side differs from the base → take that side — a change, an addition, a mode change or a deletion.
    • Both sides changed it differently:
      • both are directories → recurse into them (subtrees only one side touched are taken by oid, never read);
      • both are regular files → content merge (below); the mode one side changed wins, both changing it differently is a conflict;
      • anything else — modify/delete, a file on one side and a directory on the other, symlinks, submodules — is a conflict.
  4. Writes the merged trees and a merge commit (parents: target, then source) with git.writeCommit, then moves targetRef to it. Only targetRef moves — never the branch HEAD names. All new objects are stored as one pack (git-fs-s3’s withPackedWrites), written before the ref.

For files both sides changed to different content, threeWayMerge runs a line-level three-way merge via node-diff3 — the LCS-based diff3 algorithm GNU diffutils’ diff3 and git’s default merge driver use. A file added independently on both sides merges against an empty base. Any hunk both sides changed differently is a conflict.

Binary files (git’s heuristic: a NUL byte in any of the three versions) are never line-merged: both sides changing one differently is a conflict.

If any path conflicts, threeWayMerge throws GitMergeConflictError once every path has been examined — conflictingPaths: string[] (sorted) lists them all. Nothing is written: no merged trees, no commit, no ref change.

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

A merge merges each directory’s entries concurrently, so a wide directory would otherwise issue one object-store request per entry at once. threeWayMerge bounds the object reads and writes in flight to 16 per call. Each permit wraps one readTree, readBlob, writeTree, or writeBlob call and is released before the recursive subtree merge, so a permit is never held while waiting on a child merge — a wide tree cannot deadlock itself.

If you need a cheap pre-merge check before calling threeWayMerge — resolve both refs and check ancestry, without walking any trees — use analyzeMerge from git-fs-s3/ops. It shares the same { fs, gitdir, cache? } shape and never detects content conflicts; only a real threeWayMerge call does that.

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.