diff --git a/lib/libgit2dart.dart b/lib/libgit2dart.dart index 1bfaabb..74445e6 100644 --- a/lib/libgit2dart.dart +++ b/lib/libgit2dart.dart @@ -12,5 +12,6 @@ export 'src/revwalk.dart'; export 'src/blob.dart'; export 'src/tag.dart'; export 'src/treebuilder.dart'; +export 'src/branch.dart'; export 'src/error.dart'; export 'src/git_types.dart'; diff --git a/lib/src/bindings/branch.dart b/lib/src/bindings/branch.dart new file mode 100644 index 0000000..fd1b5e0 --- /dev/null +++ b/lib/src/bindings/branch.dart @@ -0,0 +1,181 @@ +import 'dart:ffi'; +import 'package:ffi/ffi.dart'; +import 'package:libgit2dart/libgit2dart.dart'; +import 'libgit2_bindings.dart'; +import 'reference.dart' as reference_bindings; +import '../error.dart'; +import '../util.dart'; + +/// Return a list of branches. +/// +/// Throws a [LibGit2Error] if error occured. +List list(Pointer repo, int listFlags) { + final iterator = calloc>(); + final iteratorError = libgit2.git_branch_iterator_new( + iterator, + repo, + listFlags, + ); + + if (iteratorError < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } + + var result = []; + var error = 0; + + while (error == 0) { + final reference = calloc>(); + final refType = calloc(); + error = libgit2.git_branch_next(reference, refType, iterator.value); + if (error == 0) { + final refName = reference_bindings.shorthand(reference.value); + result.add(refName); + calloc.free(refType); + calloc.free(reference); + } else { + break; + } + } + + libgit2.git_branch_iterator_free(iterator.value); + return result; +} + +/// Lookup a branch by its name in a repository. +/// +/// The generated reference must be freed by the user. The branch name will be checked for validity. +/// +/// Throws a [LibGit2Error] if error occured. +Pointer lookup( + Pointer repo, + String branchName, + int branchType, +) { + final out = calloc>(); + final branchNameC = branchName.toNativeUtf8().cast(); + final error = libgit2.git_branch_lookup(out, repo, branchNameC, branchType); + + calloc.free(branchNameC); + + if (error < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } else { + return out.value; + } +} + +/// Create a new branch pointing at a target commit. +/// +/// A new direct reference will be created pointing to this target commit. +/// If force is true and a reference already exists with the given name, it'll be replaced. +/// +/// The returned reference must be freed by the user. +/// +/// The branch name will be checked for validity. +/// +/// Throws a [LibGit2Error] if error occured. +Pointer create( + Pointer repo, + String branchName, + Pointer target, + bool force, +) { + final out = calloc>(); + final branchNameC = branchName.toNativeUtf8().cast(); + final forceC = force ? 1 : 0; + final error = libgit2.git_branch_create( + out, + repo, + branchNameC, + target, + forceC, + ); + + calloc.free(branchNameC); + + if (error < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } else { + return out.value; + } +} + +/// Delete an existing branch reference. +/// +/// Note that if the deletion succeeds, the reference object will not be valid anymore, +/// and will be freed. +/// +/// Throws a [LibGit2Error] if error occured. +void delete(Pointer branch) { + final error = libgit2.git_branch_delete(branch); + + if (error < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } else { + reference_bindings.free(branch); + } +} + +/// Move/rename an existing local branch reference. +/// +/// The new branch name will be checked for validity. +/// +/// Note that if the move succeeds, the old reference object will not be valid anymore, +/// and will be freed immediately. +/// +/// Throws a [LibGit2Error] if error occured. +Pointer rename( + Pointer branch, + String newBranchName, + bool force, +) { + final out = calloc>(); + final newBranchNameC = newBranchName.toNativeUtf8().cast(); + final forceC = force ? 1 : 0; + final error = libgit2.git_branch_move(out, branch, newBranchNameC, forceC); + + calloc.free(newBranchNameC); + + if (error < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } else { + reference_bindings.free(branch); + return out.value; + } +} + +/// Determine if HEAD points to the given branch. +/// +/// Throws a [LibGit2Error] if error occured. +bool isHead(Pointer branch) { + final result = libgit2.git_branch_is_head(branch); + + if (result < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } else { + return result == 1 ? true : false; + } +} + +/// Get the branch name. +/// +/// Given a reference object, this will check that it really is a branch +/// (ie. it lives under "refs/heads/" or "refs/remotes/"), and return the branch part of it. +/// +/// Throws a [LibGit2Error] if error occured. +String name(Pointer ref) { + final out = calloc>(); + final error = libgit2.git_branch_name(out, ref); + + if (error < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } else { + final result = out.value.cast().toDartString(); + calloc.free(out); + return result; + } +} + +/// Free the given reference to release memory. +void free(Pointer ref) => reference_bindings.free(ref); diff --git a/lib/src/branch.dart b/lib/src/branch.dart new file mode 100644 index 0000000..99f4571 --- /dev/null +++ b/lib/src/branch.dart @@ -0,0 +1,133 @@ +import 'dart:ffi'; +import 'bindings/libgit2_bindings.dart'; +import 'bindings/branch.dart' as bindings; +import 'bindings/reference.dart' as reference_bindings; +import 'commit.dart'; +import 'reference.dart'; +import 'repository.dart'; +import 'oid.dart'; +import 'git_types.dart'; +import 'util.dart'; + +class Branches { + /// Initializes a new instance of the [Branches] class + /// from provided [Repository] object. + Branches(Repository repo) { + _repoPointer = repo.pointer; + } + + /// Pointer to memory address for allocated repository object. + late final Pointer _repoPointer; + + /// Returns a list of all branches that can be found in a repository. + /// + /// Throws a [LibGit2Error] if error occured. + List list() => bindings.list(_repoPointer, GitBranch.all.value); + + /// Returns a list of local branches that can be found in a repository. + /// + /// Throws a [LibGit2Error] if error occured. + List get local => bindings.list(_repoPointer, GitBranch.local.value); + + /// Returns a list of remote branches that can be found in a repository. + /// + /// Throws a [LibGit2Error] if error occured. + List get remote => + bindings.list(_repoPointer, GitBranch.remote.value); + + /// Lookups a branch by its name in a repository. + /// + /// The generated reference must be freed. The branch name will be checked for validity. + /// + /// Throws a [LibGit2Error] if error occured. + Branch operator [](String branchName) { + final ref = Reference( + _repoPointer, reference_bindings.lookupDWIM(_repoPointer, branchName)); + late final GitBranch type; + ref.isBranch ? type = GitBranch.local : GitBranch.remote; + ref.free(); + + return Branch(bindings.lookup(_repoPointer, branchName, type.value)); + } + + /// Creates a new branch pointing at a [target] commit. + /// + /// A new direct reference will be created pointing to this target commit. + /// If [force] is true and a reference already exists with the given name, it'll be replaced. + /// + /// The returned reference must be freed. + /// + /// The branch name will be checked for validity. + /// + /// Throws a [LibGit2Error] if error occured. + Reference create({ + required String name, + required Commit target, + bool force = false, + }) { + final result = bindings.create( + _repoPointer, + name, + target.pointer, + force, + ); + + return Reference(_repoPointer, result); + } +} + +class Branch { + /// Initializes a new instance of [Branch] class from provided pointer to + /// branch object in memory. + /// + /// Should be freed with `free()` to release allocated memory. + Branch(this._branchPointer) { + libgit2.git_libgit2_init(); + } + + /// Pointer to memory address for allocated branch object. + late final Pointer _branchPointer; + + /// Returns the OID pointed to by a branch. + /// + /// Throws an exception if error occured. + Oid get target => Oid(reference_bindings.target(_branchPointer)); + + /// Deletes an existing branch reference. + /// + /// Note that if the deletion succeeds, the reference object will not be valid anymore, + /// and will be freed. + /// + /// Throws a [LibGit2Error] if error occured. + void delete() => bindings.delete(_branchPointer); + + /// Renames an existing local branch reference. + /// + /// The new branch name will be checked for validity. + /// + /// Note that if the move succeeds, the old reference object will not be valid anymore, + /// and will be freed immediately. + /// + /// If [force] is true, existing branch will be overwritten. + /// + /// Throws a [LibGit2Error] if error occured. + Branch rename({required String newName, bool force = false}) { + return Branch(bindings.rename(_branchPointer, newName, force)); + } + + /// Determines if HEAD points to the given branch. + /// + /// Throws a [LibGit2Error] if error occured. + bool get isHead => bindings.isHead(_branchPointer); + + /// Returns the branch name. + /// + /// Given a reference object, this will check that it really is a branch + /// (ie. it lives under "refs/heads/" or "refs/remotes/"), and return the branch part of it. + /// + /// Throws a [LibGit2Error] if error occured. + String get name => bindings.name(_branchPointer); + + /// Releases memory allocated for branch object. + void free() => bindings.free(_branchPointer); +} diff --git a/lib/src/commit.dart b/lib/src/commit.dart index 08707b6..ebd950d 100644 --- a/lib/src/commit.dart +++ b/lib/src/commit.dart @@ -25,9 +25,11 @@ class Commit { _commitPointer = bindings.lookup(repo.pointer, oid.pointer); } - /// Pointer to memory address for allocated commit object. late final Pointer _commitPointer; + /// Pointer to memory address for allocated commit object. + Pointer get pointer => _commitPointer; + /// Creates new commit in the repository. /// /// Throws a [LibGit2Error] if error occured. diff --git a/lib/src/git_types.dart b/lib/src/git_types.dart index d00da0a..233af16 100644 --- a/lib/src/git_types.dart +++ b/lib/src/git_types.dart @@ -110,3 +110,17 @@ class GitRevParse { int get value => _value; } + +/// Basic type of any Git branch. +class GitBranch { + const GitBranch._(this._value); + final int _value; + + static const local = GitBranch._(1); + + static const remote = GitBranch._(2); + + static const all = GitBranch._(3); + + int get value => _value; +} diff --git a/lib/src/repository.dart b/lib/src/repository.dart index 16d565f..5b632f2 100644 --- a/lib/src/repository.dart +++ b/lib/src/repository.dart @@ -456,4 +456,7 @@ class Repository { message: message, force: force); } + + /// Returns a [Branches] object. + Branches get branches => Branches(this); } diff --git a/test/branch_test.dart b/test/branch_test.dart new file mode 100644 index 0000000..37f9438 --- /dev/null +++ b/test/branch_test.dart @@ -0,0 +1,168 @@ +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}/branch_testrepo/'; + const lastCommit = '821ed6e80627b8769d170a293862f9fc60825226'; + const featureCommit = '5aecfa0fb97eadaac050ccb99f03c3fb65460ad4'; + + 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('Branch', () { + test('returns a list of all branches', () { + final branches = Branches(repo); + expect(branches.list(), ['feature', 'master']); + }); + + test('returns a list of local branches', () { + final branches = repo.branches.local; + expect(branches, ['feature', 'master']); + }); + + test('returns a list of remote branches for provided type', () { + final branches = repo.branches.remote; + expect(branches, []); + }); + + test('returns a branch with provided name', () { + final branch = repo.branches['master']; + expect(branch.target.sha, lastCommit); + branch.free(); + }); + + test('throws when provided name not found', () { + expect(() => repo.branches['invalid'], throwsA(isA())); + }); + + test('checks if branch is current head', () { + final masterBranch = repo.branches['master']; + final featureBranch = repo.branches['feature']; + + expect(masterBranch.isHead, true); + expect(featureBranch.isHead, false); + + masterBranch.free(); + featureBranch.free(); + }); + + test('returns name', () { + final branch = repo.branches['master']; + expect(branch.name, 'master'); + branch.free(); + }); + + group('create()', () { + test('successfully creates', () { + final commit = repo[lastCommit] as Commit; + + final ref = repo.branches.create(name: 'testing', target: commit); + final branch = repo.branches['testing']; + expect(repo.branches.list().length, 3); + expect(branch.target.sha, lastCommit); + + branch.free(); + ref.free(); + commit.free(); + }); + + test('throws when name already exists', () { + final commit = repo[lastCommit] as Commit; + + expect( + () => repo.branches.create(name: 'feature', target: commit), + throwsA(isA()), + ); + + commit.free(); + }); + + test('successfully creates with force flag when name already exists', () { + final commit = repo[lastCommit] as Commit; + + final ref = + repo.branches.create(name: 'feature', target: commit, force: true); + final branch = repo.branches['feature']; + expect(repo.branches.local.length, 2); + expect(branch.target.sha, lastCommit); + + branch.free(); + ref.free(); + commit.free(); + }); + }); + + group('delete()', () { + test('successfully deletes', () { + repo.branches['feature'].delete(); + expect(repo.branches.local.length, 1); + expect(() => repo.branches['feature'], throwsA(isA())); + }); + + test('throws when trying to delete current HEAD', () { + expect( + () => repo.branches['master'].delete(), + throwsA(isA()), + ); + }); + }); + + group('rename()', () { + test('successfully renames', () { + final renamed = repo.branches['feature'].rename(newName: 'renamed'); + final branch = repo.branches['renamed']; + + expect(renamed.target.sha, featureCommit); + expect(branch.target.sha, featureCommit); + + branch.free(); + renamed.free(); + }); + + test('throws when name already exists', () { + final branch = repo.branches['feature']; + expect( + () => branch.rename(newName: 'master'), + throwsA(isA()), + ); + branch.free(); + }); + + test('successfully renames with force flag when name already exists', () { + final renamed = repo.branches['master'].rename( + newName: 'feature', + force: true, + ); + + expect(renamed.target.sha, lastCommit); + + renamed.free(); + }); + + test('throws when name is invalid', () { + final branch = repo.branches['feature']; + expect( + () => branch.rename(newName: 'inv@{id'), + throwsA(isA()), + ); + branch.free(); + }); + }); + }); +}