feat(merge): add more bindings and API methods

This commit is contained in:
Aleksey Kulikov 2021-12-21 17:02:07 +03:00
parent 561986ebfd
commit 4a6fcda4c2
5 changed files with 394 additions and 41 deletions

View file

@ -24,6 +24,66 @@ Pointer<git_oid> mergeBase({
} }
} }
/// Find a merge base given a list of commits.
///
/// Throws a [LibGit2Error] if error occured.
Pointer<git_oid> mergeBaseMany({
required Pointer<git_repository> repoPointer,
required List<git_oid> commits,
}) {
final out = calloc<git_oid>();
final commitsC = calloc<git_oid>(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<git_oid> mergeBaseOctopus({
required Pointer<git_repository> repoPointer,
required List<git_oid> commits,
}) {
final out = calloc<git_oid>();
final commitsC = calloc<git_oid>(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 /// Analyzes the given branch(es) and determines the opportunities for merging
/// them into a reference. /// them into a reference.
List<int> analysis({ List<int> analysis({
@ -59,9 +119,15 @@ void merge({
required Pointer<git_repository> repoPointer, required Pointer<git_repository> repoPointer,
required Pointer<Pointer<git_annotated_commit>> theirHeadsPointer, required Pointer<Pointer<git_annotated_commit>> theirHeadsPointer,
required int theirHeadsLen, required int theirHeadsLen,
required int favor,
required int mergeFlags,
required int fileFlags,
}) { }) {
final mergeOpts = calloc<git_merge_options>(); final mergeOpts = _initMergeOptions(
libgit2.git_merge_options_init(mergeOpts, GIT_MERGE_OPTIONS_VERSION); favor: favor,
mergeFlags: mergeFlags,
fileFlags: fileFlags,
);
final checkoutOpts = calloc<git_checkout_options>(); final checkoutOpts = calloc<git_checkout_options>();
libgit2.git_checkout_options_init(checkoutOpts, GIT_CHECKOUT_OPTIONS_VERSION); libgit2.git_checkout_options_init(checkoutOpts, GIT_CHECKOUT_OPTIONS_VERSION);
@ -82,6 +148,63 @@ void merge({
calloc.free(checkoutOpts); 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<git_merge_file_result>();
final ancestorC = calloc<git_merge_file_input>();
final oursC = calloc<git_merge_file_input>();
final theirsC = calloc<git_merge_file_input>();
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<Int8>();
ancestorC.ref.size = ancestor.length;
oursC.ref.ptr = ours.toNativeUtf8().cast<Int8>();
oursC.ref.size = ours.length;
theirsC.ref.ptr = theirs.toNativeUtf8().cast<Int8>();
theirsC.ref.size = theirs.length;
final opts = calloc<git_merge_file_options>();
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<Int8>();
}
if (oursLabel.isNotEmpty) {
opts.ref.our_label = oursLabel.toNativeUtf8().cast<Int8>();
}
if (theirsLabel.isNotEmpty) {
opts.ref.their_label = theirsLabel.toNativeUtf8().cast<Int8>();
}
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<Utf8>().toDartString(length: out.ref.len);
calloc.free(out);
return result;
}
/// Merge two files as they exist in the index, using the given common ancestor /// 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 /// as the baseline, producing a string that reflects the merge result
/// containing possible conflicts. /// containing possible conflicts.

View file

@ -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. /// Throws a [LibGit2Error] if error occured.
Oid mergeBase({required Oid a, required Oid b}) { Oid mergeBase(List<Oid> 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<Oid> commits) {
return Oid( return Oid(
merge_bindings.mergeBase( merge_bindings.mergeBaseOctopus(
repoPointer: _repoPointer, repoPointer: _repoPointer,
aPointer: a.pointer, commits: commits.map((e) => e.pointer.ref).toList(),
bPointer: b.pointer,
), ),
); );
} }
@ -1004,8 +1023,24 @@ class Repository {
/// are written to the index. Callers should inspect the repository's index /// are written to the index. Callers should inspect the repository's index
/// after this completes, resolve any conflicts and prepare a commit. /// 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. /// Throws a [LibGit2Error] if error occured.
void merge(Oid oid) { void merge({
required Oid oid,
GitMergeFileFavor favor = GitMergeFileFavor.normal,
Set<GitMergeFlag> mergeFlags = const {GitMergeFlag.findRenames},
Set<GitMergeFileFlag> fileFlags = const {GitMergeFileFlag.defaults},
}) {
final theirHead = AnnotatedCommit.lookup( final theirHead = AnnotatedCommit.lookup(
repo: this, repo: this,
oid: oid, oid: oid,
@ -1015,11 +1050,64 @@ class Repository {
repoPointer: _repoPointer, repoPointer: _repoPointer,
theirHeadsPointer: theirHead.pointer, theirHeadsPointer: theirHead.pointer,
theirHeadsLen: 1, 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(); 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<GitMergeFileFlag> 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 /// 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 /// given common [ancestor] as the baseline, producing a string that reflects
/// the merge result containing possible conflicts. /// the merge result containing possible conflicts.

View file

@ -311,7 +311,7 @@ void main() {
final conflictBranch = repo.lookupBranch(name: 'conflict-branch'); final conflictBranch = repo.lookupBranch(name: 'conflict-branch');
final index = repo.index; final index = repo.index;
repo.merge(conflictBranch.target); repo.merge(oid: conflictBranch.target);
expect(() => index.writeTree(), throwsA(isA<LibGit2Error>())); expect(() => index.writeTree(), throwsA(isA<LibGit2Error>()));
@ -344,7 +344,7 @@ void main() {
conflictRepo.checkout(refName: 'refs/heads/feature'); conflictRepo.checkout(refName: 'refs/heads/feature');
conflictRepo.merge(conflictBranch.target); conflictRepo.merge(oid: conflictBranch.target);
final index = conflictRepo.index; final index = conflictRepo.index;
final conflictedFile = index.conflicts['feature_file']!; final conflictedFile = index.conflicts['feature_file']!;
@ -365,7 +365,7 @@ void main() {
final conflictBranch = conflictRepo.lookupBranch(name: 'conflict-branch'); final conflictBranch = conflictRepo.lookupBranch(name: 'conflict-branch');
conflictRepo.merge(conflictBranch.target); conflictRepo.merge(oid: conflictBranch.target);
final index = conflictRepo.index; final index = conflictRepo.index;
final conflictedFile = index.conflicts['conflict_file']!; final conflictedFile = index.conflicts['conflict_file']!;
@ -390,7 +390,7 @@ void main() {
conflictRepo.checkout(refName: 'refs/heads/our-conflict'); conflictRepo.checkout(refName: 'refs/heads/our-conflict');
conflictRepo.merge(conflictBranch.target); conflictRepo.merge(oid: conflictBranch.target);
final index = conflictRepo.index; final index = conflictRepo.index;
final conflictedFile = index.conflicts['feature_file']!; final conflictedFile = index.conflicts['feature_file']!;
@ -413,7 +413,7 @@ void main() {
conflictRepo.checkout(refName: 'refs/heads/feature'); conflictRepo.checkout(refName: 'refs/heads/feature');
conflictRepo.merge(conflictBranch.target); conflictRepo.merge(oid: conflictBranch.target);
final index = conflictRepo.index; final index = conflictRepo.index;
final conflictedFile = index.conflicts['feature_file']!; final conflictedFile = index.conflicts['feature_file']!;
@ -435,7 +435,7 @@ void main() {
final conflictBranch = conflictRepo.lookupBranch(name: 'conflict-branch'); final conflictBranch = conflictRepo.lookupBranch(name: 'conflict-branch');
final index = conflictRepo.index; final index = conflictRepo.index;
conflictRepo.merge(conflictBranch.target); conflictRepo.merge(oid: conflictBranch.target);
expect(index.hasConflicts, true); expect(index.hasConflicts, true);
expect(index['.gitignore'].isConflict, false); expect(index['.gitignore'].isConflict, false);
expect(index.conflicts['conflict_file']!.our!.isConflict, true); expect(index.conflicts['conflict_file']!.our!.isConflict, true);
@ -468,7 +468,7 @@ void main() {
final conflictBranch = conflictRepo.lookupBranch(name: 'conflict-branch'); final conflictBranch = conflictRepo.lookupBranch(name: 'conflict-branch');
final index = conflictRepo.index; final index = conflictRepo.index;
conflictRepo.merge(conflictBranch.target); conflictRepo.merge(oid: conflictBranch.target);
expect(index.hasConflicts, true); expect(index.hasConflicts, true);
expect(index.conflicts.length, 1); expect(index.conflicts.length, 1);

View file

@ -77,7 +77,7 @@ void main() {
final result = repo.mergeAnalysis(theirHead: conflictBranch.target); final result = repo.mergeAnalysis(theirHead: conflictBranch.target);
expect(result[0], {GitMergeAnalysis.normal}); expect(result[0], {GitMergeAnalysis.normal});
repo.merge(conflictBranch.target); repo.merge(oid: conflictBranch.target);
expect(index.hasConflicts, true); expect(index.hasConflicts, true);
expect(index.conflicts.length, 1); expect(index.conflicts.length, 1);
expect(repo.state, GitRepositoryState.merge); expect(repo.state, GitRepositoryState.merge);
@ -109,7 +109,7 @@ void main() {
}); });
group('merge file from index', () { group('merge file from index', () {
test('successfully merges without ancestor', () { test('merges without ancestor', () {
const diffExpected = """ const diffExpected = """
\<<<<<<< conflict_file \<<<<<<< conflict_file
master conflict edit master conflict edit
@ -119,7 +119,7 @@ conflict branch edit
"""; """;
final conflictBranch = repo.lookupBranch(name: 'conflict-branch'); final conflictBranch = repo.lookupBranch(name: 'conflict-branch');
final index = repo.index; final index = repo.index;
repo.merge(conflictBranch.target); repo.merge(oid: conflictBranch.target);
final conflictedFile = index.conflicts['conflict_file']!; final conflictedFile = index.conflicts['conflict_file']!;
final diff = repo.mergeFileFromIndex( final diff = repo.mergeFileFromIndex(
@ -128,16 +128,13 @@ conflict branch edit
theirs: conflictedFile.their!, theirs: conflictedFile.their!,
); );
expect( expect(diff, diffExpected);
diff,
diffExpected,
);
index.free(); index.free();
conflictBranch.free(); conflictBranch.free();
}); });
test('successfully merges with ancestor', () { test('merges with ancestor', () {
const diffExpected = """ const diffExpected = """
\<<<<<<< feature_file \<<<<<<< feature_file
Feature edit on feature branch Feature edit on feature branch
@ -148,7 +145,7 @@ Another feature edit
final conflictBranch = repo.lookupBranch(name: 'ancestor-conflict'); final conflictBranch = repo.lookupBranch(name: 'ancestor-conflict');
repo.checkout(refName: 'refs/heads/feature'); repo.checkout(refName: 'refs/heads/feature');
final index = repo.index; final index = repo.index;
repo.merge(conflictBranch.target); repo.merge(oid: conflictBranch.target);
final conflictedFile = index.conflicts['feature_file']!; final conflictedFile = index.conflicts['feature_file']!;
final diff = repo.mergeFileFromIndex( final diff = repo.mergeFileFromIndex(
@ -157,9 +154,50 @@ Another feature edit
theirs: conflictedFile.their!, 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( expect(
diff, File('${repo.workdir}conflict_file').readAsStringSync(),
diffExpected, 'master conflict edit\n',
); );
index.free(); 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', () { group('merge commits', () {
test('successfully merges with default values', () { test('successfully merges with default values', () {
final theirCommit = repo.lookupCommit(repo['5aecfa0']); final theirCommit = repo.lookupCommit(repo['5aecfa0']);
@ -190,7 +283,7 @@ Another feature edit
expect(mergeIndex.conflicts, isEmpty); expect(mergeIndex.conflicts, isEmpty);
final mergeCommitsTree = mergeIndex.writeTree(repo); final mergeCommitsTree = mergeIndex.writeTree(repo);
repo.merge(theirCommit.oid); repo.merge(oid: theirCommit.oid);
final index = repo.index; final index = repo.index;
expect(index.conflicts, isEmpty); expect(index.conflicts, isEmpty);
final mergeTree = index.writeTree(); final mergeTree = index.writeTree();
@ -254,17 +347,72 @@ Another feature edit
}); });
}); });
test('successfully finds merge base', () { test('finds merge base for two commits', () {
var base = repo.mergeBase(a: repo['1490545'], b: repo['5aecfa0']); var base = repo.mergeBase([repo['1490545'], repo['5aecfa0']]);
expect(base.sha, 'fc38877b2552ab554752d9a77e1f48f738cca79b'); 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'); expect(base.sha, 'f17d0d48eae3aa08cecf29128a35e310c97b3521');
}); });
test('throws when trying to find merge base for invalid oid', () { test('throws when trying to find merge base for invalid oid', () {
expect( expect(
() => repo.mergeBase(a: repo['0' * 40], b: repo['5aecfa0']), () => repo.mergeBase([repo['0' * 40], repo['5aecfa0']]),
throwsA(isA<LibGit2Error>()),
);
expect(
() => repo.mergeBase(
[
repo['0' * 40],
repo['5aecfa0'],
repo['0e409d6'],
],
),
throwsA(isA<LibGit2Error>()),
);
});
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<LibGit2Error>()), throwsA(isA<LibGit2Error>()),
); );
}); });
@ -274,10 +422,7 @@ Another feature edit
final theirCommit = repo.lookupCommit(repo['5aecfa0']); final theirCommit = repo.lookupCommit(repo['5aecfa0']);
final ourCommit = repo.lookupCommit(repo['1490545']); final ourCommit = repo.lookupCommit(repo['1490545']);
final baseCommit = repo.lookupCommit( final baseCommit = repo.lookupCommit(
repo.mergeBase( repo.mergeBase([ourCommit.oid, theirCommit.oid]),
a: ourCommit.oid,
b: theirCommit.oid,
),
); );
final theirTree = theirCommit.tree; final theirTree = theirCommit.tree;
final ourTree = ourCommit.tree; final ourTree = ourCommit.tree;
@ -292,7 +437,7 @@ Another feature edit
final mergeTreesTree = mergeIndex.writeTree(repo); final mergeTreesTree = mergeIndex.writeTree(repo);
repo.setHead(ourCommit.oid); repo.setHead(ourCommit.oid);
repo.merge(theirCommit.oid); repo.merge(oid: theirCommit.oid);
final index = repo.index; final index = repo.index;
expect(index.conflicts, isEmpty); expect(index.conflicts, isEmpty);
final mergeTree = index.writeTree(); final mergeTree = index.writeTree();
@ -313,10 +458,7 @@ Another feature edit
final theirCommit = repo.lookupCommit(repo['5aecfa0']); final theirCommit = repo.lookupCommit(repo['5aecfa0']);
final ourCommit = repo.lookupCommit(repo['1490545']); final ourCommit = repo.lookupCommit(repo['1490545']);
final baseCommit = repo.lookupCommit( final baseCommit = repo.lookupCommit(
repo.mergeBase( repo.mergeBase([ourCommit.oid, theirCommit.oid]),
a: ourCommit.oid,
b: theirCommit.oid,
),
); );
final theirTree = theirCommit.tree; final theirTree = theirCommit.tree;
final ourTree = ourCommit.tree; final ourTree = ourCommit.tree;

View file

@ -115,7 +115,7 @@ void main() {
expect(revspec.to?.oid.sha, '5aecfa0fb97eadaac050ccb99f03c3fb65460ad4'); expect(revspec.to?.oid.sha, '5aecfa0fb97eadaac050ccb99f03c3fb65460ad4');
expect(revspec.flags, {GitRevSpec.range, GitRevSpec.mergeBase}); expect(revspec.flags, {GitRevSpec.range, GitRevSpec.mergeBase});
expect( expect(
repo.mergeBase(a: revspec.from.oid, b: revspec.to!.oid), repo.mergeBase([revspec.from.oid, revspec.to!.oid]),
isA<Oid>(), isA<Oid>(),
); );