From 9791b6324cecfd09ac8e0d951eaa1eb8daab009f Mon Sep 17 00:00:00 2001 From: Aleksey Kulikov Date: Tue, 21 Dec 2021 17:11:41 +0300 Subject: [PATCH] feat(merge)!: add more bindings and API methods (#24) --- lib/src/bindings/merge.dart | 127 +++++++++++++++++++++++- lib/src/repository.dart | 100 +++++++++++++++++-- test/index_test.dart | 14 +-- test/merge_test.dart | 192 +++++++++++++++++++++++++++++++----- test/revparse_test.dart | 2 +- 5 files changed, 394 insertions(+), 41 deletions(-) diff --git a/lib/src/bindings/merge.dart b/lib/src/bindings/merge.dart index 3e4c5c2..f278804 100644 --- a/lib/src/bindings/merge.dart +++ b/lib/src/bindings/merge.dart @@ -24,6 +24,66 @@ Pointer mergeBase({ } } +/// Find a merge base given a list of commits. +/// +/// Throws a [LibGit2Error] if error occured. +Pointer mergeBaseMany({ + required Pointer repoPointer, + required List commits, +}) { + final out = calloc(); + final commitsC = calloc(commits.length * 20); + for (var i = 0; i < commits.length; i++) { + commitsC[i].id = commits[i].id; + } + + final error = libgit2.git_merge_base_many( + out, + repoPointer, + commits.length, + commitsC, + ); + + calloc.free(commitsC); + + if (error < 0) { + calloc.free(out); + throw LibGit2Error(libgit2.git_error_last()); + } else { + return out; + } +} + +/// Find a merge base in preparation for an octopus merge. +/// +/// Throws a [LibGit2Error] if error occured. +Pointer mergeBaseOctopus({ + required Pointer repoPointer, + required List commits, +}) { + final out = calloc(); + final commitsC = calloc(commits.length * 20); + for (var i = 0; i < commits.length; i++) { + commitsC[i].id = commits[i].id; + } + + final error = libgit2.git_merge_base_octopus( + out, + repoPointer, + commits.length, + commitsC, + ); + + calloc.free(commitsC); + + if (error < 0) { + calloc.free(out); + throw LibGit2Error(libgit2.git_error_last()); + } else { + return out; + } +} + /// Analyzes the given branch(es) and determines the opportunities for merging /// them into a reference. List analysis({ @@ -59,9 +119,15 @@ void merge({ required Pointer repoPointer, required Pointer> theirHeadsPointer, required int theirHeadsLen, + required int favor, + required int mergeFlags, + required int fileFlags, }) { - final mergeOpts = calloc(); - libgit2.git_merge_options_init(mergeOpts, GIT_MERGE_OPTIONS_VERSION); + final mergeOpts = _initMergeOptions( + favor: favor, + mergeFlags: mergeFlags, + fileFlags: fileFlags, + ); final checkoutOpts = calloc(); libgit2.git_checkout_options_init(checkoutOpts, GIT_CHECKOUT_OPTIONS_VERSION); @@ -82,6 +148,63 @@ void merge({ calloc.free(checkoutOpts); } +/// Merge two files as they exist in the in-memory data structures, using the +/// given common ancestor as the baseline, producing a string that reflects the +/// merge result. +/// +/// Note that this function does not reference a repository and any +/// configuration must be passed. +String mergeFile({ + required String ancestor, + required String ancestorLabel, + required String ours, + required String oursLabel, + required String theirs, + required String theirsLabel, + required int favor, + required int flags, +}) { + final out = calloc(); + final ancestorC = calloc(); + final oursC = calloc(); + final theirsC = calloc(); + libgit2.git_merge_file_input_init(ancestorC, GIT_MERGE_FILE_INPUT_VERSION); + libgit2.git_merge_file_input_init(oursC, GIT_MERGE_FILE_INPUT_VERSION); + libgit2.git_merge_file_input_init(theirsC, GIT_MERGE_FILE_INPUT_VERSION); + ancestorC.ref.ptr = ancestor.toNativeUtf8().cast(); + ancestorC.ref.size = ancestor.length; + oursC.ref.ptr = ours.toNativeUtf8().cast(); + oursC.ref.size = ours.length; + theirsC.ref.ptr = theirs.toNativeUtf8().cast(); + theirsC.ref.size = theirs.length; + + final opts = calloc(); + libgit2.git_merge_file_options_init(opts, GIT_MERGE_FILE_OPTIONS_VERSION); + opts.ref.favor = favor; + opts.ref.flags = flags; + if (ancestorLabel.isNotEmpty) { + opts.ref.ancestor_label = ancestorLabel.toNativeUtf8().cast(); + } + if (oursLabel.isNotEmpty) { + opts.ref.our_label = oursLabel.toNativeUtf8().cast(); + } + if (theirsLabel.isNotEmpty) { + opts.ref.their_label = theirsLabel.toNativeUtf8().cast(); + } + + libgit2.git_merge_file(out, ancestorC, oursC, theirsC, opts); + + calloc.free(ancestorC); + calloc.free(oursC); + calloc.free(theirsC); + calloc.free(opts); + + final result = out.ref.ptr.cast().toDartString(length: out.ref.len); + calloc.free(out); + + return result; +} + /// Merge two files as they exist in the index, using the given common ancestor /// as the baseline, producing a string that reflects the merge result /// containing possible conflicts. diff --git a/lib/src/repository.dart b/lib/src/repository.dart index 6ff6910..348e15b 100644 --- a/lib/src/repository.dart +++ b/lib/src/repository.dart @@ -949,15 +949,34 @@ class Repository { } } - /// Finds a merge base between two commits [a] and [b]. + /// Finds a merge base between [commits]. /// /// Throws a [LibGit2Error] if error occured. - Oid mergeBase({required Oid a, required Oid b}) { + Oid mergeBase(List commits) { + return commits.length == 2 + ? Oid( + merge_bindings.mergeBase( + repoPointer: _repoPointer, + aPointer: commits[0].pointer, + bPointer: commits[1].pointer, + ), + ) + : Oid( + merge_bindings.mergeBaseMany( + repoPointer: _repoPointer, + commits: commits.map((e) => e.pointer.ref).toList(), + ), + ); + } + + /// Finds a merge base in preparation for an octopus merge. + /// + /// Throws a [LibGit2Error] if error occured. + Oid mergeBaseOctopus(List commits) { return Oid( - merge_bindings.mergeBase( + merge_bindings.mergeBaseOctopus( repoPointer: _repoPointer, - aPointer: a.pointer, - bPointer: b.pointer, + commits: commits.map((e) => e.pointer.ref).toList(), ), ); } @@ -1004,8 +1023,24 @@ class Repository { /// are written to the index. Callers should inspect the repository's index /// after this completes, resolve any conflicts and prepare a commit. /// + /// [favor] is one of the [GitMergeFileFavor] flags for handling conflicting + /// content. Defaults to [GitMergeFileFavor.normal], recording conflict to t + /// he index. + /// + /// [mergeFlags] is a combination of [GitMergeFlag] flags. Defaults to + /// [GitMergeFlag.findRenames] enabling the ability to merge between a + /// modified and renamed file. + /// + /// [fileFlags] is a combination of [GitMergeFileFlag] flags. Defaults to + /// [GitMergeFileFlag.defaults]. + /// /// Throws a [LibGit2Error] if error occured. - void merge(Oid oid) { + void merge({ + required Oid oid, + GitMergeFileFavor favor = GitMergeFileFavor.normal, + Set mergeFlags = const {GitMergeFlag.findRenames}, + Set fileFlags = const {GitMergeFileFlag.defaults}, + }) { final theirHead = AnnotatedCommit.lookup( repo: this, oid: oid, @@ -1015,11 +1050,64 @@ class Repository { repoPointer: _repoPointer, theirHeadsPointer: theirHead.pointer, theirHeadsLen: 1, + favor: favor.value, + mergeFlags: mergeFlags.fold(0, (acc, e) => acc | e.value), + fileFlags: fileFlags.fold(0, (acc, e) => acc | e.value), ); theirHead.free(); } + /// Merges two files as they exist in the in-memory data structures, using the + /// given common ancestor as the baseline, producing a string that reflects + /// the merge result. + /// + /// Note that this function does not reference a repository and configuration + /// must be passed as [favor] and [flags]. + /// + /// [ancestor] is the contents of the ancestor file. + /// + /// [ancestorLabel] is optional label for the ancestor file side of the + /// conflict which will be prepended to labels in diff3-format merge files. + /// Defaults to "file.txt". + /// + /// [ours] is the contents of the file in "our" side. + /// + /// [oursLabel] is optional label for our file side of the conflict which + /// will be prepended to labels in merge files. Defaults to "file.txt". + /// + /// [theirs] is the contents of the file in "their" side. + /// + /// [theirsLabel] is optional label for their file side of the conflict which + /// will be prepended to labels in merge files. Defaults to "file.txt". + /// + /// [favor] is one of the [GitMergeFileFavor] flags for handling conflicting + /// content. Defaults to [GitMergeFileFavor.normal]. + /// + /// [flags] is a combination of [GitMergeFileFlag] flags. Defaults to + /// [GitMergeFileFlag.defaults]. + String mergeFile({ + required String ancestor, + String ancestorLabel = '', + required String ours, + String oursLabel = '', + required String theirs, + String theirsLabel = '', + GitMergeFileFavor favor = GitMergeFileFavor.normal, + Set flags = const {GitMergeFileFlag.defaults}, + }) { + return merge_bindings.mergeFile( + ancestor: ancestor, + ancestorLabel: ancestorLabel, + ours: ours, + oursLabel: oursLabel, + theirs: theirs, + theirsLabel: theirsLabel, + favor: favor.value, + flags: flags.fold(0, (acc, e) => acc | e.value), + ); + } + /// Merges two files [ours] and [theirs] as they exist in the index, using the /// given common [ancestor] as the baseline, producing a string that reflects /// the merge result containing possible conflicts. diff --git a/test/index_test.dart b/test/index_test.dart index a373ddc..b247c34 100644 --- a/test/index_test.dart +++ b/test/index_test.dart @@ -311,7 +311,7 @@ void main() { final conflictBranch = repo.lookupBranch(name: 'conflict-branch'); final index = repo.index; - repo.merge(conflictBranch.target); + repo.merge(oid: conflictBranch.target); expect(() => index.writeTree(), throwsA(isA())); @@ -344,7 +344,7 @@ void main() { conflictRepo.checkout(refName: 'refs/heads/feature'); - conflictRepo.merge(conflictBranch.target); + conflictRepo.merge(oid: conflictBranch.target); final index = conflictRepo.index; final conflictedFile = index.conflicts['feature_file']!; @@ -365,7 +365,7 @@ void main() { final conflictBranch = conflictRepo.lookupBranch(name: 'conflict-branch'); - conflictRepo.merge(conflictBranch.target); + conflictRepo.merge(oid: conflictBranch.target); final index = conflictRepo.index; final conflictedFile = index.conflicts['conflict_file']!; @@ -390,7 +390,7 @@ void main() { conflictRepo.checkout(refName: 'refs/heads/our-conflict'); - conflictRepo.merge(conflictBranch.target); + conflictRepo.merge(oid: conflictBranch.target); final index = conflictRepo.index; final conflictedFile = index.conflicts['feature_file']!; @@ -413,7 +413,7 @@ void main() { conflictRepo.checkout(refName: 'refs/heads/feature'); - conflictRepo.merge(conflictBranch.target); + conflictRepo.merge(oid: conflictBranch.target); final index = conflictRepo.index; final conflictedFile = index.conflicts['feature_file']!; @@ -435,7 +435,7 @@ void main() { final conflictBranch = conflictRepo.lookupBranch(name: 'conflict-branch'); final index = conflictRepo.index; - conflictRepo.merge(conflictBranch.target); + conflictRepo.merge(oid: conflictBranch.target); expect(index.hasConflicts, true); expect(index['.gitignore'].isConflict, false); expect(index.conflicts['conflict_file']!.our!.isConflict, true); @@ -468,7 +468,7 @@ void main() { final conflictBranch = conflictRepo.lookupBranch(name: 'conflict-branch'); final index = conflictRepo.index; - conflictRepo.merge(conflictBranch.target); + conflictRepo.merge(oid: conflictBranch.target); expect(index.hasConflicts, true); expect(index.conflicts.length, 1); diff --git a/test/merge_test.dart b/test/merge_test.dart index 0bb865a..108b6a7 100644 --- a/test/merge_test.dart +++ b/test/merge_test.dart @@ -77,7 +77,7 @@ void main() { final result = repo.mergeAnalysis(theirHead: conflictBranch.target); expect(result[0], {GitMergeAnalysis.normal}); - repo.merge(conflictBranch.target); + repo.merge(oid: conflictBranch.target); expect(index.hasConflicts, true); expect(index.conflicts.length, 1); expect(repo.state, GitRepositoryState.merge); @@ -109,7 +109,7 @@ void main() { }); group('merge file from index', () { - test('successfully merges without ancestor', () { + test('merges without ancestor', () { const diffExpected = """ \<<<<<<< conflict_file master conflict edit @@ -119,7 +119,7 @@ conflict branch edit """; final conflictBranch = repo.lookupBranch(name: 'conflict-branch'); final index = repo.index; - repo.merge(conflictBranch.target); + repo.merge(oid: conflictBranch.target); final conflictedFile = index.conflicts['conflict_file']!; final diff = repo.mergeFileFromIndex( @@ -128,16 +128,13 @@ conflict branch edit theirs: conflictedFile.their!, ); - expect( - diff, - diffExpected, - ); + expect(diff, diffExpected); index.free(); conflictBranch.free(); }); - test('successfully merges with ancestor', () { + test('merges with ancestor', () { const diffExpected = """ \<<<<<<< feature_file Feature edit on feature branch @@ -148,7 +145,7 @@ Another feature edit final conflictBranch = repo.lookupBranch(name: 'ancestor-conflict'); repo.checkout(refName: 'refs/heads/feature'); final index = repo.index; - repo.merge(conflictBranch.target); + repo.merge(oid: conflictBranch.target); final conflictedFile = index.conflicts['feature_file']!; final diff = repo.mergeFileFromIndex( @@ -157,9 +154,50 @@ Another feature edit theirs: conflictedFile.their!, ); + expect(diff, diffExpected); + + index.free(); + conflictBranch.free(); + }); + + test('merges with provided merge flags and file flags', () { + const diffExpected = """ +\<<<<<<< conflict_file +master conflict edit +======= +conflict branch edit +>>>>>>> conflict_file +"""; + final conflictBranch = repo.lookupBranch(name: 'conflict-branch'); + final index = repo.index; + repo.merge( + oid: conflictBranch.target, + mergeFlags: {GitMergeFlag.noRecursive}, + fileFlags: {GitMergeFileFlag.ignoreWhitespaceEOL}, + ); + + final conflictedFile = index.conflicts['conflict_file']!; + final diff = repo.mergeFileFromIndex( + ancestor: null, + ours: conflictedFile.our!, + theirs: conflictedFile.their!, + ); + + expect(diff, diffExpected); + + index.free(); + conflictBranch.free(); + }); + + test('merges with provided merge favor', () { + final conflictBranch = repo.lookupBranch(name: 'conflict-branch'); + final index = repo.index; + + repo.merge(oid: conflictBranch.target, favor: GitMergeFileFavor.ours); + expect(index.conflicts, isEmpty); expect( - diff, - diffExpected, + File('${repo.workdir}conflict_file').readAsStringSync(), + 'master conflict edit\n', ); index.free(); @@ -178,6 +216,61 @@ Another feature edit }); }); + group('merge file', () { + test('merges file with default values', () { + const diffExpected = """ +\<<<<<<< file.txt +ours content +======= +theirs content +>>>>>>> file.txt +"""; + final diff = repo.mergeFile( + ancestor: '', + ours: 'ours content', + theirs: 'theirs content', + ); + + expect(diff, diffExpected); + }); + + test('merges file with provided values', () { + const diffExpected = """ +\<<<<<<< ours.txt +ours content +||||||| ancestor.txt +ancestor content +======= +theirs content +>>>>>>> theirs.txt +"""; + final diff = repo.mergeFile( + ancestor: 'ancestor content', + ancestorLabel: 'ancestor.txt', + ours: 'ours content', + oursLabel: 'ours.txt', + theirs: 'theirs content', + theirsLabel: 'theirs.txt', + flags: {GitMergeFileFlag.styleDiff3}, + ); + + expect(diff, diffExpected); + }); + + test('merges file with provided favor', () { + const diffExpected = 'ours content'; + + final diff = repo.mergeFile( + ancestor: 'ancestor content', + ours: 'ours content', + theirs: 'theirs content', + favor: GitMergeFileFavor.ours, + ); + + expect(diff, diffExpected); + }); + }); + group('merge commits', () { test('successfully merges with default values', () { final theirCommit = repo.lookupCommit(repo['5aecfa0']); @@ -190,7 +283,7 @@ Another feature edit expect(mergeIndex.conflicts, isEmpty); final mergeCommitsTree = mergeIndex.writeTree(repo); - repo.merge(theirCommit.oid); + repo.merge(oid: theirCommit.oid); final index = repo.index; expect(index.conflicts, isEmpty); final mergeTree = index.writeTree(); @@ -254,17 +347,72 @@ Another feature edit }); }); - test('successfully finds merge base', () { - var base = repo.mergeBase(a: repo['1490545'], b: repo['5aecfa0']); + test('finds merge base for two commits', () { + var base = repo.mergeBase([repo['1490545'], repo['5aecfa0']]); expect(base.sha, 'fc38877b2552ab554752d9a77e1f48f738cca79b'); - base = repo.mergeBase(a: repo['f17d0d4'], b: repo['5aecfa0']); + base = repo.mergeBase([repo['f17d0d4'], repo['5aecfa0']]); + expect(base.sha, 'f17d0d48eae3aa08cecf29128a35e310c97b3521'); + }); + + test('finds merge base for many commits', () { + var base = repo.mergeBase( + [ + repo['1490545'], + repo['0e409d6'], + repo['5aecfa0'], + ], + ); + expect(base.sha, 'fc38877b2552ab554752d9a77e1f48f738cca79b'); + + base = repo.mergeBase( + [ + repo['f17d0d4'], + repo['5aecfa0'], + repo['0e409d6'], + ], + ); expect(base.sha, 'f17d0d48eae3aa08cecf29128a35e310c97b3521'); }); test('throws when trying to find merge base for invalid oid', () { expect( - () => repo.mergeBase(a: repo['0' * 40], b: repo['5aecfa0']), + () => repo.mergeBase([repo['0' * 40], repo['5aecfa0']]), + throwsA(isA()), + ); + + expect( + () => repo.mergeBase( + [ + repo['0' * 40], + repo['5aecfa0'], + repo['0e409d6'], + ], + ), + throwsA(isA()), + ); + }); + + test('finds octopus merge base', () { + final base = repo.mergeBaseOctopus( + [ + repo['1490545'], + repo['0e409d6'], + repo['5aecfa0'], + ], + ); + expect(base.sha, 'fc38877b2552ab554752d9a77e1f48f738cca79b'); + }); + + test('throws when trying to find octopus merge base for invalid oid', () { + expect( + () => repo.mergeBaseOctopus( + [ + repo['0' * 40], + repo['5aecfa0'], + repo['0e409d6'], + ], + ), throwsA(isA()), ); }); @@ -274,10 +422,7 @@ Another feature edit final theirCommit = repo.lookupCommit(repo['5aecfa0']); final ourCommit = repo.lookupCommit(repo['1490545']); final baseCommit = repo.lookupCommit( - repo.mergeBase( - a: ourCommit.oid, - b: theirCommit.oid, - ), + repo.mergeBase([ourCommit.oid, theirCommit.oid]), ); final theirTree = theirCommit.tree; final ourTree = ourCommit.tree; @@ -292,7 +437,7 @@ Another feature edit final mergeTreesTree = mergeIndex.writeTree(repo); repo.setHead(ourCommit.oid); - repo.merge(theirCommit.oid); + repo.merge(oid: theirCommit.oid); final index = repo.index; expect(index.conflicts, isEmpty); final mergeTree = index.writeTree(); @@ -313,10 +458,7 @@ Another feature edit final theirCommit = repo.lookupCommit(repo['5aecfa0']); final ourCommit = repo.lookupCommit(repo['1490545']); final baseCommit = repo.lookupCommit( - repo.mergeBase( - a: ourCommit.oid, - b: theirCommit.oid, - ), + repo.mergeBase([ourCommit.oid, theirCommit.oid]), ); final theirTree = theirCommit.tree; final ourTree = ourCommit.tree; diff --git a/test/revparse_test.dart b/test/revparse_test.dart index 290d4e4..4b25e7b 100644 --- a/test/revparse_test.dart +++ b/test/revparse_test.dart @@ -115,7 +115,7 @@ void main() { expect(revspec.to?.oid.sha, '5aecfa0fb97eadaac050ccb99f03c3fb65460ad4'); expect(revspec.flags, {GitRevSpec.range, GitRevSpec.mergeBase}); expect( - repo.mergeBase(a: revspec.from.oid, b: revspec.to!.oid), + repo.mergeBase([revspec.from.oid, revspec.to!.oid]), isA(), );