From 223cc7cc14fa64cb30e86f5fd4f5246b8d3dc154 Mon Sep 17 00:00:00 2001 From: Aleksey Kulikov Date: Wed, 8 Sep 2021 16:03:35 +0300 Subject: [PATCH] feat(merge): add bindings and api for merge analysis --- lib/src/bindings/commit.dart | 29 ++++++++++++ lib/src/bindings/merge.dart | 33 ++++++++++++++ lib/src/git_types.dart | 48 ++++++++++++++++++++ lib/src/repository.dart | 49 +++++++++++++++------ test/merge_test.dart | 85 ++++++++++++++++++++++++++++++++++++ 5 files changed, 231 insertions(+), 13 deletions(-) create mode 100644 test/merge_test.dart diff --git a/lib/src/bindings/commit.dart b/lib/src/bindings/commit.dart index b20c856..720f5d3 100644 --- a/lib/src/bindings/commit.dart +++ b/lib/src/bindings/commit.dart @@ -39,6 +39,35 @@ Pointer lookupPrefix( } } +/// Creates a git_annotated_commit from the given commit id. The resulting git_annotated_commit +/// must be freed with git_annotated_commit_free. +/// +/// An annotated commit contains information about how it was looked up, which may be useful +/// for functions like merge or rebase to provide context to the operation. For example, conflict +/// files will include the name of the source or target branches being merged. It is therefore +/// preferable to use the most specific function (eg git_annotated_commit_from_ref) instead of +/// this one when that data is known. +/// +/// Throws a [LibGit2Error] if error occured. +Pointer> annotatedLookup( + Pointer repo, + Pointer id, +) { + final out = calloc>(); + final error = libgit2.git_annotated_commit_lookup(out, repo, id); + + if (error < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } else { + return out; + } +} + +/// Frees a git_annotated_commit. +void annotatedFree(Pointer commit) { + libgit2.git_annotated_commit_free(commit); +} + /// Create new commit in the repository. /// /// Throws a [LibGit2Error] if error occured. diff --git a/lib/src/bindings/merge.dart b/lib/src/bindings/merge.dart index 4c2b08e..02932aa 100644 --- a/lib/src/bindings/merge.dart +++ b/lib/src/bindings/merge.dart @@ -21,3 +21,36 @@ Pointer mergeBase( return out; } } + +/// Analyzes the given branch(es) and determines the opportunities for merging them +/// into a reference. +/// +/// Throws a [LibGit2Error] if error occured. +List analysis( + Pointer repo, + Pointer ourRef, + Pointer> theirHead, + int theirHeadsLen, +) { + final analysisOut = calloc(); + final preferenceOut = calloc(); + final error = libgit2.git_merge_analysis_for_ref( + analysisOut, + preferenceOut, + repo, + ourRef, + theirHead, + theirHeadsLen, + ); + var result = []; + + if (error < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } else { + result.add(analysisOut.value); + result.add(preferenceOut.value); + calloc.free(analysisOut); + calloc.free(preferenceOut); + return result; + } +} diff --git a/lib/src/git_types.dart b/lib/src/git_types.dart index 70d3a8a..7303680 100644 --- a/lib/src/git_types.dart +++ b/lib/src/git_types.dart @@ -147,3 +147,51 @@ class GitStatus { int get value => _value; } + +/// The results of `mergeAnalysis` indicate the merge opportunities. +class GitMergeAnalysis { + const GitMergeAnalysis._(this._value); + final int _value; + + /// No merge is possible (unused). + static const none = GitMergeAnalysis._(0); + + /// A "normal" merge; both HEAD and the given merge input have diverged + /// from their common ancestor. The divergent commits must be merged. + static const normal = GitMergeAnalysis._(1); + + /// All given merge inputs are reachable from HEAD, meaning the + /// repository is up-to-date and no merge needs to be performed. + static const upToDate = GitMergeAnalysis._(2); + + /// The given merge input is a fast-forward from HEAD and no merge + /// needs to be performed. Instead, the client can check out the + /// given merge input. + static const fastForward = GitMergeAnalysis._(4); + + /// The HEAD of the current repository is "unborn" and does not point to + /// a valid commit. No merge can be performed, but the caller may wish + /// to simply set HEAD to the target commit(s). + static const unborn = GitMergeAnalysis._(8); + + int get value => _value; +} + +/// The user's stated preference for merges. +class GitMergePreference { + const GitMergePreference._(this._value); + final int _value; + + /// No configuration was found that suggests a preferred behavior for merge. + static const none = GitMergePreference._(0); + + /// There is a `merge.ff=false` configuration setting, suggesting that + /// the user does not want to allow a fast-forward merge. + static const noFastForward = GitMergePreference._(1); + + /// There is a `merge.ff=only` configuration setting, suggesting that + /// the user only wants fast-forward merges. + static const fastForwardOnly = GitMergePreference._(2); + + int get value => _value; +} diff --git a/lib/src/repository.dart b/lib/src/repository.dart index 6cb4bc8..7bc3e6c 100644 --- a/lib/src/repository.dart +++ b/lib/src/repository.dart @@ -5,6 +5,7 @@ import 'bindings/repository.dart' as bindings; import 'bindings/merge.dart' as merge_bindings; import 'bindings/object.dart' as object_bindings; import 'bindings/status.dart' as status_bindings; +import 'bindings/commit.dart' as commit_bindings; import 'branch.dart'; import 'commit.dart'; import 'config.dart'; @@ -399,19 +400,6 @@ class Repository { /// Throws a [LibGit2Error] if error occured. RevSpec revParse(String spec) => RevParse.range(this, spec); - /// Finds a merge base between two commits. - /// - /// Throws a [LibGit2Error] if error occured. - Oid mergeBase(String one, String two) { - final oidOne = Oid.fromSHA(this, one); - final oidTwo = Oid.fromSHA(this, two); - return Oid(merge_bindings.mergeBase( - _repoPointer, - oidOne.pointer, - oidTwo.pointer, - )); - } - /// Creates a new blob from a [content] string and writes it to ODB. /// /// Throws a [LibGit2Error] if error occured. @@ -494,4 +482,39 @@ class Repository { /// /// Throws a [LibGit2Error] if error occured. int statusFile(String path) => status_bindings.file(_repoPointer, path); + + /// Finds a merge base between two commits. + /// + /// Throws a [LibGit2Error] if error occured. + Oid mergeBase(String one, String two) { + final oidOne = Oid.fromSHA(this, one); + final oidTwo = Oid.fromSHA(this, two); + return Oid(merge_bindings.mergeBase( + _repoPointer, + oidOne.pointer, + oidTwo.pointer, + )); + } + + /// Analyzes the given branch(es) and determines the opportunities for merging them + /// into a reference (default is 'HEAD'). + /// + /// Returns list with analysis result and preference for fast forward merge values + /// respectively. + /// + /// Throws a [LibGit2Error] if error occured. + List mergeAnalysis(Oid theirHead, [String ourRef = 'HEAD']) { + final ref = references[ourRef]; + final head = commit_bindings.annotatedLookup( + _repoPointer, + theirHead.pointer, + ); + + final result = merge_bindings.analysis(_repoPointer, ref.pointer, head, 1); + + commit_bindings.annotatedFree(head.value); + ref.free(); + + return result; + } } diff --git a/test/merge_test.dart b/test/merge_test.dart new file mode 100644 index 0000000..9d96bcf --- /dev/null +++ b/test/merge_test.dart @@ -0,0 +1,85 @@ +import 'dart:io'; +import 'package:test/test.dart'; +import 'package:libgit2dart/libgit2dart.dart'; +import 'helpers/util.dart'; + +void main() { + late Repository repo; + final tmpDir = '${Directory.systemTemp.path}/merge_testrepo/'; + + setUp(() async { + if (await Directory(tmpDir).exists()) { + await Directory(tmpDir).delete(recursive: true); + } + await copyRepo( + from: Directory('test/assets/testrepo/'), + to: await Directory(tmpDir).create(), + ); + repo = Repository.open(tmpDir); + }); + + tearDown(() async { + repo.free(); + await Directory(tmpDir).delete(recursive: true); + }); + + group('Merge', () { + group('analysis', () { + test('is up to date when no reference is provided', () { + final commit = + repo['c68ff54aabf660fcdd9a2838d401583fe31249e3'] as Commit; + final result = repo.mergeAnalysis(commit.id); + + expect(result[0], GitMergeAnalysis.upToDate.value); + expect(repo.status, isEmpty); + + commit.free(); + }); + + test('is up to date for provided ref', () { + final commit = + repo['c68ff54aabf660fcdd9a2838d401583fe31249e3'] as Commit; + final result = repo.mergeAnalysis(commit.id, 'refs/tags/v0.1'); + + expect(result[0], GitMergeAnalysis.upToDate.value); + expect(repo.status, isEmpty); + + commit.free(); + }); + + test('is fast forward', () { + final theirHead = + repo['6cbc22e509d72758ab4c8d9f287ea846b90c448b'] as Commit; + final ffCommit = + repo['f17d0d48eae3aa08cecf29128a35e310c97b3521'] as Commit; + final ffBranch = repo.branches.create( + name: 'ff-branch', + target: ffCommit, + ); + + final result = repo.mergeAnalysis(theirHead.id, ffBranch.name); + + expect( + result[0], + GitMergeAnalysis.fastForward.value + GitMergeAnalysis.normal.value, + ); + expect(repo.status, isEmpty); + + ffBranch.free(); + ffCommit.free(); + theirHead.free(); + }); + + test('is not fast forward and there is no conflicts', () { + final commit = + repo['5aecfa0fb97eadaac050ccb99f03c3fb65460ad4'] as Commit; + final result = repo.mergeAnalysis(commit.id); + + expect(result[0], GitMergeAnalysis.normal.value); + expect(repo.status, isEmpty); + + commit.free(); + }); + }); + }); +}