Skip to content

Commit ff64624

Browse files
fix(merge): allow merges of new folders with the same name (#61)
* allow merges of new folders with the same name * add more unit tests --------- Co-authored-by: Alex Titarenko <3290313+alex-titarenko@users.noreply.github.com>
1 parent ddfadd0 commit ff64624

File tree

4 files changed

+216
-17
lines changed

4 files changed

+216
-17
lines changed

src/errors/MergeConflictError.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { BaseError } from './BaseError'
2+
3+
export type MergeConflictErrorData = {
4+
filePath: string
5+
}
6+
7+
export class MergeConflictError extends BaseError<MergeConflictErrorData> {
8+
public static readonly code = 'MergeConflictError'
9+
10+
constructor(filePath: string) {
11+
super(
12+
`Automatic merge failed with a conflict in the following path: ${filePath}.`,
13+
MergeConflictError.code,
14+
{ filePath }
15+
)
16+
}
17+
}
18+

src/errors/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export * from './InvalidFilepathError'
1515
export * from './InvalidOidError'
1616
export * from './InvalidRefNameError'
1717
export * from './MaxDepthError'
18+
export * from './MergeConflictError'
1819
export * from './MergeNotSupportedError'
1920
export * from './MissingNameError'
2021
export * from './MissingParameterError'

src/utils/mergeTree.ts

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
1-
import { Buffer } from 'buffer'
1+
import { BlobMergeCallback, BlobMergeCallbackParams, Cache, WalkerEntry } from '../models'
22

3+
import { Buffer } from 'buffer'
34
import { FileSystem } from '../models/FileSystem'
4-
import { BlobMergeCallback, BlobMergeCallbackParams, Cache, WalkerEntry } from '../models'
5+
import { GitTree } from '../models/GitTree'
6+
import { MergeConflictError } from 'git-essentials/errors'
57
import { TREE } from '../commands/TREE'
68
import { _walk } from '../commands/walk'
7-
import { MergeNotSupportedError } from '../errors/MergeNotSupportedError'
8-
import { GitTree } from '../models/GitTree'
9-
import { _writeObject as writeObject } from '../storage/writeObject'
10-
119
import { basename } from './basename'
1210
import { isBinary } from './isBinary'
1311
import { join } from './join'
1412
import { mergeFile } from './mergeFile'
13+
import { _writeObject as writeObject } from '../storage/writeObject'
1514

1615
type MergeTreeParams = {
1716
/** A file system helper. */
@@ -140,7 +139,19 @@ export async function mergeTree({
140139
(base && (await base.type()) !== 'blob') ||
141140
(theirs && (await theirs.type()) !== 'blob')
142141
) {
143-
throw new MergeNotSupportedError()
142+
if (
143+
(ours && (await ours.type()) === 'tree') &&
144+
!base &&
145+
(theirs && (await theirs.type()) === 'tree')) {
146+
return {
147+
mode: await theirs.mode(),
148+
path,
149+
oid: await theirs.oid(),
150+
type: await theirs.type(),
151+
}
152+
}
153+
154+
throw new MergeConflictError(filepath)
144155
}
145156

146157
// Modifications
@@ -307,7 +318,7 @@ async function defaultBlobMergeCallback({
307318
!theirContent ||
308319
isBinary(theirContent)
309320
) {
310-
throw new MergeNotSupportedError('Merge of binary data is not supported.')
321+
throw new MergeConflictError(filePath)
311322
}
312323

313324
// if both sides made changes do a merge
@@ -322,7 +333,7 @@ async function defaultBlobMergeCallback({
322333

323334
if (!cleanMerge) {
324335
// all other types of conflicts fail
325-
throw new MergeNotSupportedError('Merge with conflicts is not supported.')
336+
throw new MergeConflictError(filePath)
326337
}
327338

328339
const mode =
@@ -331,6 +342,6 @@ async function defaultBlobMergeCallback({
331342
: await our.mode()
332343
return { mergedText: mergedText, mode: mode }
333344
} else {
334-
throw new MergeNotSupportedError()
345+
throw new MergeConflictError(filePath)
335346
}
336347
}

tests/merge.test.ts

Lines changed: 176 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
1-
import { merge, resolveRef, log, BlobMergeCallback, checkout } from 'git-essentials'
2-
import { MissingNameError, MergeNotSupportedError } from 'git-essentials/errors'
1+
import * as path from './helpers/path'
2+
3+
import {
4+
BlobMergeCallback,
5+
add,
6+
branch,
7+
checkout,
8+
commit,
9+
init,
10+
log,
11+
merge,
12+
resolveRef
13+
} from 'git-essentials'
14+
import { FsFixtureData, makeFsFixture } from './helpers/makeFsFixture'
15+
import { MergeConflictError, MergeNotSupportedError, MissingNameError } from 'git-essentials/errors'
316

4-
import { makeFsFixture, FsFixtureData } from './helpers/makeFsFixture'
517
import { expectToFailWithTypeAsync } from './helpers/assertionHelper'
6-
718
import mergeFsFixtureData from './fixtures/fs/merge.json'
819
import mergeNoFastForwardFsFixtureData from './fixtures/fs/merge-no-ff.json'
920

10-
1121
const author = {
1222
name: 'Mr. Test',
1323
email: 'mrtest@example.com',
@@ -257,7 +267,7 @@ describe('merge', () => {
257267
}
258268

259269
// assert
260-
await expectToFailWithTypeAsync(action, MergeNotSupportedError)
270+
await expectToFailWithTypeAsync(action, MergeConflictError)
261271
})
262272

263273
it("merge two branches that modified the same file (no conflict)'", async () => {
@@ -302,7 +312,7 @@ describe('merge', () => {
302312
}
303313

304314
// assert
305-
await expectToFailWithTypeAsync(action, MergeNotSupportedError)
315+
await expectToFailWithTypeAsync(action, MergeConflictError)
306316
})
307317

308318
it("merge two branches that modified the same file, custom conflict resolver (prefer our changes)", async () => {
@@ -385,3 +395,162 @@ describe('merge', () => {
385395
expect(conflictedFile).toBe('text\nfile\nwas\nmodified\n')
386396
})
387397
})
398+
399+
describe('merge-e2e', () => {
400+
const branch1Name = 'branch1'
401+
const branch2Name = 'branch2'
402+
const newDirName = 'new-dir'
403+
404+
it('merge new folders with the same name', async () => {
405+
// ARRANGE
406+
const { fs, dir } = await makeFsFixture()
407+
408+
// initializing new repo
409+
await init({ fs, dir, defaultBranch: branch1Name })
410+
await commit({ fs, dir, message: 'first commit', author: { name: 'author0' } })
411+
await branch({ fs, dir, ref: branch2Name, checkout: false })
412+
413+
// writing files to the branch1
414+
await fs.mkdir(path.resolve(dir, newDirName))
415+
await fs.writeFile(path.resolve(dir, newDirName, 'new-file.txt'), 'some content')
416+
await fs.writeFile(path.resolve(dir, newDirName, 'new-file1.txt'), 'some content 1')
417+
await add({ fs, dir, filepath: path.resolve(newDirName, 'new-file.txt') })
418+
await add({ fs, dir, filepath: path.resolve(newDirName, 'new-file1.txt') })
419+
await commit({ fs, dir, message: 'add files', author: { name: 'author1' } })
420+
421+
// writing files to the branch2
422+
await checkout({ fs, dir, ref: branch2Name })
423+
await fs.mkdir(path.resolve(dir, newDirName))
424+
await fs.writeFile(path.resolve(dir, newDirName, 'new-file.txt'), 'some content')
425+
await fs.writeFile(path.resolve(dir, newDirName, 'new-file2.txt'), 'some content 2')
426+
await add({ fs, dir, filepath: path.resolve(newDirName, 'new-file.txt') })
427+
await add({ fs, dir, filepath: path.resolve(newDirName, 'new-file2.txt') })
428+
await commit({ fs, dir, message: 'add files', author: { name: 'author2' } })
429+
430+
// switching back to the branch1
431+
await checkout({ fs, dir, ref: branch1Name })
432+
433+
// ACT
434+
const m = await merge({ fs, dir, ours: branch1Name, theirs: branch2Name, author: { name: 'author3' } })
435+
await checkout({ fs, dir, ref: branch1Name })
436+
437+
// ASSERT
438+
expect(m.alreadyMerged).toBeFalsy()
439+
expect(m.mergeCommit).toBeTruthy()
440+
const newDirFiles = await fs.readdir(path.resolve(dir, newDirName))
441+
expect(newDirFiles.length).toBe(3)
442+
})
443+
444+
it('merge subfolders with new parent folder with the same name', async () => {
445+
// ARRANGE
446+
const { fs, dir } = await makeFsFixture()
447+
448+
// initializing new repo
449+
await init({ fs, dir, defaultBranch: branch1Name })
450+
await commit({ fs, dir, message: 'first commit', author: { name: 'author0' } })
451+
await branch({ fs, dir, ref: branch2Name, checkout: false })
452+
453+
// writing files to the branch1
454+
await fs.mkdir(path.resolve(dir, newDirName))
455+
await fs.mkdir(path.resolve(dir, newDirName, 'sub-folder1'))
456+
await fs.writeFile(path.resolve(dir, newDirName, 'sub-folder1', 'new-file.txt'), 'some content 1')
457+
await add({ fs, dir, filepath: path.resolve(newDirName, 'sub-folder1', 'new-file.txt') })
458+
await commit({ fs, dir, message: 'add files', author: { name: 'author1' } })
459+
460+
// writing files to a branch2
461+
await checkout({ fs, dir, ref: branch2Name })
462+
await fs.mkdir(path.resolve(dir, newDirName))
463+
await fs.mkdir(path.resolve(dir, newDirName, 'sub-folder2'))
464+
await fs.writeFile(path.resolve(dir, newDirName, 'sub-folder2', 'new-file.txt'), 'some content 2')
465+
await add({ fs, dir, filepath: path.resolve(newDirName, 'sub-folder2', 'new-file.txt') })
466+
await commit({ fs, dir, message: 'add files', author: { name: 'author2' } })
467+
468+
// switching back to the branch1
469+
await checkout({ fs, dir, ref: branch1Name })
470+
471+
// ACT
472+
const m = await merge({ fs, dir, ours: branch1Name, theirs: branch2Name, author: { name: 'author3' } })
473+
await checkout({ fs, dir, ref: branch1Name })
474+
475+
// ASSERT
476+
expect(m.alreadyMerged).toBeFalsy()
477+
expect(m.mergeCommit).toBeTruthy()
478+
const newDirFiles = await fs.readdir(path.resolve(dir, newDirName))
479+
expect(newDirFiles.length).toBe(2)
480+
})
481+
482+
it('merge subfolders with the same name who have a new parent with the same name', async () => {
483+
// ARRANGE
484+
const { fs, dir } = await makeFsFixture()
485+
486+
// initializing new repo
487+
await init({ fs, dir, defaultBranch: branch1Name })
488+
await commit({ fs, dir, message: 'first commit', author: { name: 'author0' } })
489+
await branch({ fs, dir, ref: branch2Name, checkout: false })
490+
491+
// writing files to the branch1
492+
await fs.mkdir(path.resolve(dir, newDirName))
493+
await fs.mkdir(path.resolve(dir, newDirName, 'sub-folder'))
494+
await fs.writeFile(path.resolve(dir, newDirName, 'sub-folder', 'new-file1.txt'), 'some content 1')
495+
await add({ fs, dir, filepath: path.resolve(newDirName, 'sub-folder', 'new-file1.txt') })
496+
await commit({ fs, dir, message: 'add files', author: { name: 'author1' } })
497+
498+
// writing files to a branch2
499+
await checkout({ fs, dir, ref: branch2Name })
500+
await fs.mkdir(path.resolve(dir, newDirName))
501+
await fs.mkdir(path.resolve(dir, newDirName, 'sub-folder'))
502+
await fs.writeFile(path.resolve(dir, newDirName, 'sub-folder', 'new-file2.txt'), 'some content 2')
503+
await add({ fs, dir, filepath: path.resolve(newDirName, 'sub-folder', 'new-file2.txt') })
504+
await commit({ fs, dir, message: 'add files', author: { name: 'author2' } })
505+
506+
// switching back to the branch1
507+
await checkout({ fs, dir, ref: branch1Name })
508+
509+
// ACT
510+
const m = await merge({ fs, dir, ours: branch1Name, theirs: branch2Name, author: { name: 'author3' } })
511+
await checkout({ fs, dir, ref: branch1Name })
512+
513+
// ASSERT
514+
expect(m.alreadyMerged).toBeFalsy()
515+
expect(m.mergeCommit).toBeTruthy()
516+
const newDirFiles = await fs.readdir(path.resolve(dir, newDirName))
517+
expect(newDirFiles.length).toBe(1)
518+
const newSubDirFiles = await fs.readdir(path.resolve(dir, newDirName, 'sub-folder'))
519+
expect(newSubDirFiles.length).toBe(2)
520+
})
521+
522+
it('merge new folder and file with the same name should fail', async () => {
523+
// ARRANGE
524+
const { fs, dir } = await makeFsFixture()
525+
526+
// initializing new repo
527+
await init({ fs, dir, defaultBranch: branch1Name })
528+
await commit({ fs, dir, message: 'first commit', author: { name: 'author0' } })
529+
await branch({ fs, dir, ref: branch2Name, checkout: false })
530+
531+
// writing files to the branch1
532+
await fs.mkdir(path.resolve(dir, newDirName))
533+
await fs.writeFile(path.resolve(dir, newDirName, 'new-file.txt'), 'some content')
534+
await fs.writeFile(path.resolve(dir, newDirName, 'new-file1.txt'), 'some content 1')
535+
await add({ fs, dir, filepath: path.resolve(newDirName, 'new-file.txt') })
536+
await add({ fs, dir, filepath: path.resolve(newDirName, 'new-file1.txt') })
537+
await commit({ fs, dir, message: 'add files', author: { name: 'author1' } })
538+
539+
// writing files to the branch2
540+
await checkout({ fs, dir, ref: branch2Name })
541+
await fs.writeFile(path.resolve(dir, newDirName), 'some content')
542+
await add({ fs, dir, filepath: newDirName })
543+
await commit({ fs, dir, message: 'add files', author: { name: 'author2' } })
544+
545+
// switching back to the branch1
546+
await checkout({ fs, dir, ref: branch1Name })
547+
548+
// ACT
549+
const action = async () => {
550+
await merge({ fs, dir, ours: branch1Name, theirs: branch2Name, author: { name: 'author3' } })
551+
}
552+
553+
// ASSERT
554+
await expectToFailWithTypeAsync(action, MergeConflictError)
555+
})
556+
})

0 commit comments

Comments
 (0)