diff --git a/lib/libgit2dart.dart b/lib/libgit2dart.dart index 2735eb6..58ea756 100644 --- a/lib/libgit2dart.dart +++ b/lib/libgit2dart.dart @@ -23,6 +23,7 @@ export 'src/callbacks.dart'; export 'src/credentials.dart'; export 'src/blame.dart'; export 'src/note.dart'; +export 'src/rebase.dart'; export 'src/features.dart'; export 'src/error.dart'; export 'src/git_types.dart'; diff --git a/lib/src/bindings/rebase.dart b/lib/src/bindings/rebase.dart new file mode 100644 index 0000000..ed26847 --- /dev/null +++ b/lib/src/bindings/rebase.dart @@ -0,0 +1,129 @@ +import 'dart:ffi'; +import 'package:ffi/ffi.dart'; +import 'libgit2_bindings.dart'; +import '../error.dart'; +import '../util.dart'; + +/// Initializes a rebase operation to rebase the changes in [branchPointer] relative +/// to [upstreamPointer] onto [ontoPointer] another branch. To begin the rebase process, call +/// `next()`. When you have finished with this object, call `free()`. +/// +/// [branchPointer] is the terminal commit to rebase, or null to rebase the current branch. +/// +/// [upstreamPointer] is the commit to begin rebasing from, or null to rebase all +/// reachable commits. +/// +/// [ontoPointer] is the branch to rebase onto, or null to rebase onto the given upstream. +/// +/// Throws a [LibGit2Error] if error occured. +Pointer init({ + required Pointer repoPointer, + required Pointer? branchPointer, + required Pointer? upstreamPointer, + required Pointer? ontoPointer, +}) { + final out = calloc>(); + final opts = calloc(); + + final optsError = libgit2.git_rebase_options_init( + opts, + GIT_REBASE_OPTIONS_VERSION, + ); + + if (optsError < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } + + final error = libgit2.git_rebase_init( + out, + repoPointer, + branchPointer ?? nullptr, + upstreamPointer ?? nullptr, + ontoPointer ?? nullptr, + opts, + ); + + if (error < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } else { + return out.value; + } +} + +/// Gets the count of rebase operations that are to be applied. +int operationsCount(Pointer rebase) { + return libgit2.git_rebase_operation_entrycount(rebase); +} + +/// Performs the next rebase operation and returns the information about it. +/// If the operation is one that applies a patch (which is any operation except +/// GIT_REBASE_OPERATION_EXEC) then the patch will be applied and the index and +/// working directory will be updated with the changes. If there are conflicts, +/// you will need to address those before committing the changes. +/// +/// Throws a [LibGit2Error] if error occured. +Pointer next(Pointer rebase) { + final operation = calloc>(); + final error = libgit2.git_rebase_next(operation, rebase); + + if (error < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } else { + return operation.value; + } +} + +/// Commits the current patch. You must have resolved any conflicts that were +/// introduced during the patch application from the `next()` invocation. +/// +/// Throws a [LibGit2Error] if error occured. +void commit({ + required Pointer rebasePointer, + required Pointer? authorPointer, + required Pointer committerPointer, + required String? message, +}) { + final id = calloc(); + final messageC = message?.toNativeUtf8().cast() ?? nullptr; + + final error = libgit2.git_rebase_commit( + id, + rebasePointer, + authorPointer ?? nullptr, + committerPointer, + nullptr, + messageC, + ); + + calloc.free(messageC); + + if (error < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } +} + +/// Finishes a rebase that is currently in progress once all patches have been applied. +/// +/// Throws a [LibGit2Error] if error occured. +void finish(Pointer rebase) { + final error = libgit2.git_rebase_finish(rebase, nullptr); + + if (error < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } +} + +/// Aborts a rebase that is currently in progress, resetting the repository and working +/// directory to their state before rebase began. +/// +/// Throws a [LibGit2Error] if error occured. +void abort(Pointer rebase) { + final error = libgit2.git_rebase_abort(rebase); + + if (error < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } +} + +/// Free memory allocated for rebase object. +void free(Pointer rebase) => libgit2.git_rebase_free(rebase); diff --git a/lib/src/git_types.dart b/lib/src/git_types.dart index cd81aa9..0d3f4cd 100644 --- a/lib/src/git_types.dart +++ b/lib/src/git_types.dart @@ -1453,3 +1453,48 @@ class GitBlameFlag { @override String toString() => 'GitBlameFlag.$_name'; } + +/// Type of rebase operation in-progress after calling rebase's `next()`. +class GitRebaseOperation { + const GitRebaseOperation._(this._value, this._name); + final int _value; + final String _name; + + /// The given commit is to be cherry-picked. The client should commit + /// the changes and continue if there are no conflicts. + static const pick = GitRebaseOperation._(0, 'pick'); + + /// The given commit is to be cherry-picked, but the client should prompt + /// the user to provide an updated commit message. + static const reword = GitRebaseOperation._(1, 'reword'); + + /// The given commit is to be cherry-picked, but the client should stop + /// to allow the user to edit the changes before committing them. + static const edit = GitRebaseOperation._(2, 'edit'); + + /// The given commit is to be squashed into the previous commit. The + /// commit message will be merged with the previous message. + static const squash = GitRebaseOperation._(3, 'squash'); + + /// The given commit is to be squashed into the previous commit. The + /// commit message from this commit will be discarded. + static const fixup = GitRebaseOperation._(4, 'fixup'); + + /// No commit will be cherry-picked. The client should run the given + /// command and (if successful) continue. + static const exec = GitRebaseOperation._(5, 'exec'); + + static const List values = [ + pick, + reword, + edit, + squash, + fixup, + exec, + ]; + + int get value => _value; + + @override + String toString() => 'GitRebaseOperation.$_name'; +} diff --git a/lib/src/rebase.dart b/lib/src/rebase.dart new file mode 100644 index 0000000..e520690 --- /dev/null +++ b/lib/src/rebase.dart @@ -0,0 +1,148 @@ +import 'dart:ffi'; +import 'package:ffi/ffi.dart'; +import 'bindings/libgit2_bindings.dart'; +import 'bindings/rebase.dart' as bindings; +import 'bindings/commit.dart' as commit_bindings; +import 'git_types.dart'; +import 'oid.dart'; +import 'repository.dart'; +import 'signature.dart'; + +class Rebase { + /// Initializes a new instance of the [Rebase] class by initializing a + /// rebase operation to rebase the changes in [branch] relative to [upstream] + /// [onto] another branch. To begin the rebase process, call `next()`. + /// When you have finished with this object, call `free()`. + /// + /// [branch] is the terminal commit to rebase, default is to rebase the current branch. + /// + /// [upstream] is the commit to begin rebasing from, default is to rebase all + /// reachable commits. + /// + /// [onto] is the branch to rebase onto, default is to rebase onto the given [upstream] + /// (throws if [upstream] is not provided). + /// + /// Throws a [LibGit2Error] if error occured. + Rebase.init({ + required Repository repo, + Oid? branch, + Oid? upstream, + Oid? onto, + }) { + Pointer? _branch, _upstream, _onto; + if (branch != null) { + _branch = commit_bindings + .annotatedLookup( + repoPointer: repo.pointer, + oidPointer: branch.pointer, + ) + .value; + } + if (upstream != null) { + _upstream = commit_bindings + .annotatedLookup( + repoPointer: repo.pointer, + oidPointer: upstream.pointer, + ) + .value; + } + if (onto != null) { + _onto = commit_bindings + .annotatedLookup( + repoPointer: repo.pointer, + oidPointer: onto.pointer, + ) + .value; + } + + _rebasePointer = bindings.init( + repoPointer: repo.pointer, + branchPointer: _branch, + upstreamPointer: _upstream, + ontoPointer: _onto, + ); + } + + /// Pointer to memory address for allocated rebase object. + late final Pointer _rebasePointer; + + /// The count of rebase operations that are to be applied. + int get operationsCount { + return bindings.operationsCount(_rebasePointer); + } + + /// Performs the next rebase operation and returns the information about it. + /// If the operation is one that applies a patch (which is any operation except + /// [GitRebaseOperation.exec]) then the patch will be applied and the index and + /// working directory will be updated with the changes. If there are conflicts, + /// you will need to address those before committing the changes. + /// + /// Throws a [LibGit2Error] if error occured. + RebaseOperation next() { + return RebaseOperation(bindings.next(_rebasePointer)); + } + + /// Commits the current patch. You must have resolved any conflicts that were + /// introduced during the patch application from the `next()` invocation. + /// + /// Throws a [LibGit2Error] if error occured. + void commit({ + required Signature committer, + String? message, + Signature? author, + }) { + bindings.commit( + rebasePointer: _rebasePointer, + authorPointer: author?.pointer, + committerPointer: committer.pointer, + message: message, + ); + } + + /// Finishes a rebase that is currently in progress once all patches have been applied. + /// + /// Throws a [LibGit2Error] if error occured. + void finish() => bindings.finish(_rebasePointer); + + /// Aborts a rebase that is currently in progress, resetting the repository and working + /// directory to their state before rebase began. + /// + /// Throws a [LibGit2Error] if error occured. + void abort() => bindings.abort(_rebasePointer); + + /// Releases memory allocated for rebase object. + void free() => bindings.free(_rebasePointer); +} + +class RebaseOperation { + /// Initializes a new instance of the [RebaseOperation] class from + /// provided pointer to rebase operation object in memory. + const RebaseOperation(this._rebaseOperationPointer); + + /// Pointer to memory address for allocated rebase operation object. + final Pointer _rebaseOperationPointer; + + /// Returns the type of rebase operation. + GitRebaseOperation get type { + late final GitRebaseOperation result; + for (var operation in GitRebaseOperation.values) { + if (_rebaseOperationPointer.ref.type == operation.value) { + result = operation; + break; + } + } + return result; + } + + /// The commit ID being cherry-picked. This will be populated for + /// all operations except those of type [GitRebaseOperation.exec]. + Oid get id => Oid.fromRaw(_rebaseOperationPointer.ref.id); + + /// The executable the user has requested be run. This will only + /// be populated for operations of type [GitRebaseOperation.exec]. + String get exec { + return _rebaseOperationPointer.ref.exec == nullptr + ? '' + : _rebaseOperationPointer.ref.exec.cast().toDartString(); + } +} diff --git a/test/rebase_test.dart b/test/rebase_test.dart new file mode 100644 index 0000000..98c2cdc --- /dev/null +++ b/test/rebase_test.dart @@ -0,0 +1,153 @@ +import 'dart:io'; +import 'package:libgit2dart/src/git_types.dart'; +import 'package:test/test.dart'; +import 'package:libgit2dart/libgit2dart.dart'; +import 'helpers/util.dart'; + +void main() { + late Repository repo; + late Directory tmpDir; + const shas = [ + 'c68ff54aabf660fcdd9a2838d401583fe31249e3', + '821ed6e80627b8769d170a293862f9fc60825226', + '14905459d775f3f56a39ebc2ff081163f7da3529', + ]; + + setUp(() async { + tmpDir = await setupRepo(Directory('test/assets/mergerepo/')); + repo = Repository.open(tmpDir.path); + }); + + tearDown(() async { + repo.free(); + await tmpDir.delete(recursive: true); + }); + + group('Rebase', () { + test('successfully performs rebase when there is no conflicts', () { + final signature = repo.defaultSignature; + final master = repo.references['refs/heads/master']; + final feature = repo.references['refs/heads/feature']; + + repo.checkout(refName: feature.name); + expect(() => repo.index['.gitignore'], throwsA(isA())); + + final rebase = Rebase.init( + repo: repo, + branch: master.target, + onto: feature.target, + ); + + final operationsCount = rebase.operationsCount; + expect(operationsCount, 3); + + for (var i = 0; i < operationsCount; i++) { + final operation = rebase.next(); + expect(operation.type, GitRebaseOperation.pick); + expect(operation.id.sha, shas[i]); + expect(operation.exec, ''); + + rebase.commit(committer: signature); + } + + rebase.finish(); + expect(repo.index['.gitignore'], isA()); + + rebase.free(); + feature.free(); + master.free(); + signature.free(); + }); + + test('successfully performs rebase with provided upstream', () { + final signature = repo.defaultSignature; + final master = repo.references['refs/heads/master']; + final feature = repo.references['refs/heads/feature']; + final startCommit = repo[shas[1]] as Commit; + + repo.checkout(refName: feature.name); + expect( + () => repo.index['conflict_file'], + throwsA(isA()), + ); + + final rebase = Rebase.init( + repo: repo, + branch: master.target, + onto: feature.target, + upstream: startCommit.id, + ); + + final operationsCount = rebase.operationsCount; + expect(operationsCount, 1); + + for (var i = 0; i < operationsCount; i++) { + rebase.next(); + rebase.commit(committer: signature); + } + + rebase.finish(); + expect(repo.index['conflict_file'], isA()); + + rebase.free(); + startCommit.free(); + feature.free(); + master.free(); + signature.free(); + }); + + test('stops when there is conflicts', () { + final signature = repo.defaultSignature; + final master = repo.references['refs/heads/master']; + final conflict = repo.references['refs/heads/conflict-branch']; + + repo.checkout(refName: conflict.name); + + final rebase = Rebase.init( + repo: repo, + branch: master.target, + onto: conflict.target, + ); + expect(rebase.operationsCount, 1); + + rebase.next(); + expect(repo.status['conflict_file'], {GitStatus.conflicted}); + expect(repo.state, GitRepositoryState.rebaseMerge); + expect( + () => rebase.commit(committer: signature), + throwsA(isA()), + ); + + rebase.free(); + conflict.free(); + master.free(); + signature.free(); + }); + + test('successfully aborts rebase in progress', () { + final master = repo.references['refs/heads/master']; + final conflict = repo.references['refs/heads/conflict-branch']; + + repo.checkout(refName: conflict.name); + + final rebase = Rebase.init( + repo: repo, + branch: master.target, + onto: conflict.target, + ); + expect(rebase.operationsCount, 1); + + rebase.next(); + expect(repo.status['conflict_file'], {GitStatus.conflicted}); + expect(repo.state, GitRepositoryState.rebaseMerge); + + rebase.abort(); + expect(repo.status, isEmpty); + expect(repo.state, GitRepositoryState.none); + + rebase.free(); + conflict.free(); + master.free(); + }); + }); +}