Repository: isomorphic-git/isomorphic-git
Pull request #2121 (2025-06-26 04:46:49)

feat(merge --allow-unrelated-histories): adding parameter to merge unrelated branches
<!-- Oh wow! Thanks for opening a pull request! 😁 🎉 -->
<!-- You are very welcome here and any contribution is appreciated. 👍 -->
<!-- Choose one of the checklists if it applies to you and delete the rest. -->

## I'm adding a parameter to an existing command merge:

- [x] add parameter to the function in `src/api/merge.js` (and `src/commands/merge.js` if necessary)
- [x] document the parameter in the JSDoc comment above the function
- [x] add a test case in `__tests__/test-merge.js` if possible

### Problem
Unable to `merge` when the two branches shared **no common ancestor**.  
In Git CLI this is handled with `git merge --allow-unrelated-histories`, but our API always threw `MergeNotSupportedError`, forcing users to resort to manual work-arounds.

### Solution
* **New flag** – `allowUnrelatedHistories` (default `false`) added to `merge`
* When no merge base is found **and** the flag is `true`, we fall back to the empty-tree OID (`4b825dc6…`) as a synthetic base so the standard three-way merge can proceed.
* **Docs updated** to describe the flag.
* **Tests added** covering:
  * Merging unrelated branches that add different files.
  * Merging unrelated branches that add the *same* filename with diverging content.

### Usage
```js
await git.merge({
  fs,
  dir,
  ours: 'main',
  theirs: 'other',
  allowUnrelatedHistories: true,
})


Patch:
diff --git a/src/api/merge.js b/src/api/merge.js
index 90aa9f2b..36535856 100644
--- a/src/api/merge.js
+++ b/src/api/merge.js
@@ -106,6 +106,7 @@ import { normalizeCommitterObject } from '../utils/normalizeCommitterObject.js'
  * @param {string} [args.signingKey] - passed to [commit](commit.md) when creating a merge commit
  * @param {object} [args.cache] - a [cache](cache.md) object
  * @param {MergeDriverCallback} [args.mergeDriver] - a [merge driver](mergeDriver.md) implementation
+ * @param {boolean} [args.allowUnrelatedHistories = false] - If true, allows merging histories of two branches that started their lives independently.
  *
  * @returns {Promise<MergeResult>} Resolves to a description of the merge operation
  * @see MergeResult
@@ -138,6 +139,7 @@ export async function merge({
   signingKey,
   cache = {},
   mergeDriver,
+  allowUnrelatedHistories = false,
 }) {
   try {
     assertParameter('fs', _fs)
@@ -179,6 +181,7 @@ export async function merge({
       signingKey,
       onSign,
       mergeDriver,
+      allowUnrelatedHistories,
     })
   } catch (err) {
     err.caller = 'git.merge'
diff --git a/src/commands/merge.js b/src/commands/merge.js
index 220f56ff..3918e73c 100644
--- a/src/commands/merge.js
+++ b/src/commands/merge.js
@@ -50,6 +50,7 @@ import { mergeTree } from '../utils/mergeTree.js'
  * @param {string} [args.signingKey]
  * @param {SignCallback} [args.onSign] - a PGP signing implementation
  * @param {MergeDriverCallback} [args.mergeDriver]
+ * @param {boolean} args.allowUnrelatedHistories
  *
  * @returns {Promise<MergeResult>} Resolves to a description of the merge operation
  *
@@ -72,6 +73,7 @@ export async function _merge({
   signingKey,
   onSign,
   mergeDriver,
+  allowUnrelatedHistories = false,
 }) {
   if (ours === undefined) {
     ours = await _currentBranch({ fs, gitdir, fullname: true })
@@ -104,8 +106,13 @@ export async function _merge({
     oids: [ourOid, theirOid],
   })
   if (baseOids.length !== 1) {
-    // TODO: Recursive Merge strategy
-    throw new MergeNotSupportedError()
+    if (baseOids.length === 0 && allowUnrelatedHistories) {
+      // 4b825…  == the empty tree used by git
+      baseOids.push('4b825dc642cb6eb9a060e54bf8d69288fbee4904')
+    } else {
+      // TODO: Recursive Merge strategy
+      throw new MergeNotSupportedError()
+    }
   }
   const baseOid = baseOids[0]
   // handle fast-forward case
diff --git a/src/utils/mergeTree.js b/src/utils/mergeTree.js
index 3468261c..cb82e49c 100644
--- a/src/utils/mergeTree.js
+++ b/src/utils/mergeTree.js
@@ -123,10 +123,8 @@ export async function mergeTree({
           // Modifications
           if (
             ours &&
-            base &&
             theirs &&
             (await ours.type()) === 'blob' &&
-            (await base.type()) === 'blob' &&
             (await theirs.type()) === 'blob'
           ) {
             return mergeBlobs({
@@ -145,13 +143,18 @@ export async function mergeTree({
                 unmergedFiles.push(filepath)
                 bothModified.push(filepath)
                 if (!abortOnConflict) {
-                  const baseOid = await base.oid()
+                  let baseOid = ''
+                  if (base && (await base.type()) === 'blob') {
+                    baseOid = await base.oid()
+                  }
                   const ourOid = await ours.oid()
                   const theirOid = await theirs.oid()
 
                   index.delete({ filepath })
 
-                  index.insert({ filepath, oid: baseOid, stage: 1 })
+                  if (baseOid) {
+                    index.insert({ filepath, oid: baseOid, stage: 1 })
+                  }
                   index.insert({ filepath, oid: ourOid, stage: 2 })
                   index.insert({ filepath, oid: theirOid, stage: 3 })
                 }
@@ -338,10 +341,16 @@ async function mergeBlobs({
   const type = 'blob'
   // Compute the new mode.
   // Since there are ONLY two valid blob modes ('100755' and '100644') it boils down to this
+  let baseMode = '100755'
+  let baseOid = ''
+  let baseContent = ''
+  if (base && (await base.type()) === 'blob') {
+    baseMode = await base.mode()
+    baseOid = await base.oid()
+    baseContent = Buffer.from(await base.content()).toString('utf8')
+  }
   const mode =
-    (await base.mode()) === (await ours.mode())
-      ? await theirs.mode()
-      : await ours.mode()
+    baseMode === (await ours.mode()) ? await theirs.mode() : await ours.mode()
   // The trivial case: nothing to merge except maybe mode
   if ((await ours.oid()) === (await theirs.oid())) {
     return {
@@ -350,13 +359,13 @@ async function mergeBlobs({
     }
   }
   // if only one side made oid changes, return that side's oid
-  if ((await ours.oid()) === (await base.oid())) {
+  if ((await ours.oid()) === baseOid) {
     return {
       cleanMerge: true,
       mergeResult: { mode, path, oid: await theirs.oid(), type },
     }
   }
-  if ((await theirs.oid()) === (await base.oid())) {
+  if ((await theirs.oid()) === baseOid) {
     return {
       cleanMerge: true,
       mergeResult: { mode, path, oid: await ours.oid(), type },
@@ -364,7 +373,6 @@ async function mergeBlobs({
   }
   // if both sides made changes do a merge
   const ourContent = Buffer.from(await ours.content()).toString('utf8')
-  const baseContent = Buffer.from(await base.content()).toString('utf8')
   const theirContent = Buffer.from(await theirs.content()).toString('utf8')
   const { mergedText, cleanMerge } = await mergeDriver({
     branches: [baseName, ourName, theirName],
diff --git a/website/versioned_docs/version-1.x/merge.md b/website/versioned_docs/version-1.x/merge.md
index c8b9ae9b..a397ec77 100644
--- a/website/versioned_docs/version-1.x/merge.md
+++ b/website/versioned_docs/version-1.x/merge.md
@@ -34,6 +34,7 @@ Merge two branches
 | signingKey               | string                               | passed to [commit](commit.md) when creating a merge commit                                                                                                    |
 | cache                    | object                               | a [cache](cache.md) object                                                                                                                                    |
 | mergeDriver              | MergeDriverCallback                  | a [merge driver](mergeDriver.md) implementation                                                                                                               |
+| allowUnrelatedHistories  | boolean = false                      | If true, allows merging histories of two branches that started their lives independently.                                                                     |
 | return                   | Promise\<MergeResult\>               | Resolves to a description of the merge operation                                                                                                              |
 
 Returns an object with a schema like this:


Test patch:
diff --git a/__tests__/test-merge.js b/__tests__/test-merge.js
index 273487d7..519db3d0 100644
--- a/__tests__/test-merge.js
+++ b/__tests__/test-merge.js
@@ -1,6 +1,13 @@
 /* eslint-env node, browser, jasmine */
 const diff3Merge = require('diff3')
-const { Errors, merge, add, resolveRef, log } = require('isomorphic-git')
+const {
+  Errors,
+  merge,
+  add,
+  resolveRef,
+  log,
+  statusMatrix,
+} = require('isomorphic-git')
 const gitCommit = require('isomorphic-git').commit
 
 const { makeFixture } = require('./__helpers__/FixtureFS.js')
@@ -1174,4 +1181,131 @@ describe('merge', () => {
     expect(mergeCommit.message).toEqual(commit.message)
     expect(mergeCommit.parent).toEqual(commit.parent)
   })
+  it('merge two branches with unrelated histories where they add 2 files having different name', async () => {
+    const { fs, dir, gitdir } = await makeFixture('test-empty')
+
+    const author = {
+      name: 'Mr. Test',
+      email: 'mrtest@example.com',
+      timestamp: 1262356920,
+      timezoneOffset: -0,
+    }
+
+    // First root commit on 'master'
+    await fs.write(`${dir}/a.txt`, 'hello a')
+    await add({ fs, dir, gitdir, filepath: 'a.txt' })
+    await gitCommit({
+      fs,
+      dir,
+      gitdir,
+      ref: 'master',
+      message: 'Add a.txt',
+      author,
+    })
+
+    // Second root commit on unrelated branch 'other'
+    await fs.write(`${dir}/b.txt`, 'hello b')
+    await add({ fs, dir, gitdir, filepath: 'b.txt' })
+    await gitCommit({
+      fs,
+      dir,
+      gitdir,
+      ref: 'other',
+      message: 'Add b.txt',
+      author,
+    })
+
+    const report = await merge({
+      fs,
+      gitdir,
+      ours: 'master',
+      theirs: 'other',
+      abortOnConflict: false,
+      allowUnrelatedHistories: true,
+      author,
+    })
+
+    expect(report).toBeTruthy()
+    expect(report.mergeCommit).toBeTruthy()
+    const mergeHead = (await log({ fs, gitdir, ref: 'master', depth: 1 }))[0]
+      .commit
+    expect(mergeHead.parent.length).toBe(2)
+
+    const matrix = await statusMatrix({ fs, dir, gitdir })
+    const trackedFiles = matrix.map(row => row[0])
+    expect(trackedFiles.join()).toEqual(['a.txt', 'b.txt'].join())
+
+    const history = await log({ fs, gitdir, ref: 'master', depth: 3 })
+    const messages = history.map(entry =>
+      entry.commit.message.replace('\n', '')
+    )
+    expect(messages.join()).toEqual(
+      [`Merge branch 'other' into master`, 'Add b.txt', 'Add a.txt'].join()
+    )
+  })
+
+  it('merge two branches with unrelated histories where they add 2 files having same name', async () => {
+    const { fs, dir, gitdir } = await makeFixture('test-empty')
+    const author = {
+      name: 'Mr. Test',
+      email: 'mrtest@example.com',
+      timestamp: 1262356920,
+      timezoneOffset: -0,
+    }
+
+    // First root commit on master adding same.txt
+    await fs.write(`${dir}/same.txt`, 'content from master')
+    await add({ fs, dir, gitdir, filepath: 'same.txt' })
+    await gitCommit({
+      fs,
+      dir,
+      gitdir,
+      ref: 'master',
+      message: 'Add same.txt on master',
+      author,
+    })
+
+    // Second unrelated root commit on branch 'other' adding same.txt with different content
+    await fs.write(`${dir}/same.txt`, 'content from other')
+    await add({ fs, dir, gitdir, filepath: 'same.txt' })
+    await gitCommit({
+      fs,
+      dir,
+      gitdir,
+      ref: 'other',
+      parent: [],
+      message: 'Add same.txt on other',
+      author,
+    })
+
+    let error = null
+    try {
+      await merge({
+        fs,
+        dir,
+        gitdir,
+        ours: 'master',
+        theirs: 'other',
+        abortOnConflict: false,
+        allowUnrelatedHistories: true,
+        author,
+      })
+    } catch (e) {
+      error = e
+    }
+    expect(error).not.toBeNull()
+    expect(error.code).toBe(Errors.MergeConflictError.code)
+    const resultText = await fs.read(`${dir}/same.txt`, 'utf8')
+    expect(resultText).toContain('<<<<<<<')
+
+    const matrix = await statusMatrix({ fs, dir, gitdir })
+    const trackedFiles = matrix.map(row => row[0])
+    expect(trackedFiles.join()).toEqual(['same.txt'].join())
+
+    const history = await log({ fs, gitdir, ref: 'master', depth: 3 })
+    const messages = history.map(entry =>
+      entry.commit.message.replace('\n', '')
+    )
+    expect(messages.join()).toEqual(['Add same.txt on master'].join())
+  })
 })