Repository: isomorphic-git/isomorphic-git
Pull request #2122 (2025-06-28 22:11:30)

fix: mergeTree handles modifications in both trees


## I'm fixing a bug or typo

- [x] squash merge the PR with commit message "fix: [Description of fix]"

## Summary
This PR fixes a bug in the mergeTree utility where it didn't properly handle the case when both trees had modifications to the same directory structure. The fix adds specific handling for "tree-tree" merges when both sides have modified the same directory path, preserving the directory structure without creating conflicts for directories themselves.

## Changes
- Added explicit handling for the case when both sides of a merge contain tree objects at the same path
- Added comprehensive tests for merging branches with nested directory structures
- Ensured proper conflict handling when files with the same path have different content

## Testing
Added two new test cases:
1. Merging branches with unrelated histories where they add files in different nested directories
2. Merging branches with unrelated histories where they add files with the same path but different content



Patch:
diff --git a/src/utils/mergeTree.js b/src/utils/mergeTree.js
index cb82e49c..6e2624ae 100644
--- a/src/utils/mergeTree.js
+++ b/src/utils/mergeTree.js
@@ -120,7 +120,22 @@ export async function mergeTree({
             : undefined
         }
         case 'true-true': {
-          // Modifications
+          // Handle tree-tree merges (directories)
+          if (
+            ours &&
+            theirs &&
+            (await ours.type()) === 'tree' &&
+            (await theirs.type()) === 'tree'
+          ) {
+            return {
+              mode: await ours.mode(),
+              path,
+              oid: await ours.oid(),
+              type: 'tree',
+            }
+          }
+
+          // Modifications - both are blobs
           if (
             ours &&
             theirs &&


Test patch:
diff --git a/__tests__/test-merge.js b/__tests__/test-merge.js
index 519db3d0..05a8a81f 100644
--- a/__tests__/test-merge.js
+++ b/__tests__/test-merge.js
@@ -1308,4 +1308,146 @@ describe('merge', () => {
     )
     expect(messages.join()).toEqual(['Add same.txt on master'].join())
   })
+
+  it('merge two branches with unrelated histories where they add files in nested directories', 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' with nested directory
+    await fs.mkdir(`${dir}/dir1`)
+    await fs.mkdir(`${dir}/dir1/subdir1`)
+    await fs.write(`${dir}/dir1/subdir1/file1.txt`, 'content from master')
+    await add({ fs, dir, gitdir, filepath: 'dir1/subdir1/file1.txt' })
+    await gitCommit({
+      fs,
+      dir,
+      gitdir,
+      ref: 'master',
+      message: 'Add file in nested directory on master',
+      author,
+    })
+
+    // Second root commit on unrelated branch 'other' with different nested directory
+    await fs.mkdir(`${dir}/dir2`)
+    await fs.mkdir(`${dir}/dir2/subdir2`)
+    await fs.write(`${dir}/dir2/subdir2/file2.txt`, 'content from other')
+    await add({ fs, dir, gitdir, filepath: 'dir2/subdir2/file2.txt' })
+    await gitCommit({
+      fs,
+      dir,
+      gitdir,
+      ref: 'other',
+      message: 'Add file in different nested directory on other',
+      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(
+      ['dir1/subdir1/file1.txt', 'dir2/subdir2/file2.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 file in different nested directory on other',
+        'Add file in nested directory on master',
+      ].join()
+    )
+  })
+
+  it('merge two branches with unrelated histories where they add files with same path in nested directories', 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 nested file
+    await fs.mkdir(`${dir}/shared`)
+    await fs.mkdir(`${dir}/shared/path`)
+    await fs.write(`${dir}/shared/path/conflict.txt`, 'content from master')
+    await add({ fs, dir, gitdir, filepath: 'shared/path/conflict.txt' })
+    await gitCommit({
+      fs,
+      dir,
+      gitdir,
+      ref: 'master',
+      message: 'Add nested file on master',
+      author,
+    })
+
+    // Second unrelated root commit on branch 'other' adding same nested file with different content
+    await fs.mkdir(`${dir}/shared`)
+    await fs.mkdir(`${dir}/shared/path`)
+    await fs.write(`${dir}/shared/path/conflict.txt`, 'content from other')
+    await add({ fs, dir, gitdir, filepath: 'shared/path/conflict.txt' })
+    await gitCommit({
+      fs,
+      dir,
+      gitdir,
+      ref: 'other',
+      parent: [],
+      message: 'Add nested file 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}/shared/path/conflict.txt`, 'utf8')
+    expect(resultText).toContain('<<<<<<<')
+
+    const matrix = await statusMatrix({ fs, dir, gitdir })
+    const trackedFiles = matrix.map(row => row[0])
+    expect(trackedFiles.join()).toEqual(['shared/path/conflict.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 nested file on master'].join())
+  })
 })