From 2e0486c6417bdd5ad93b13abb71708e9f5b31121 Mon Sep 17 00:00:00 2001 From: Aleksey Kulikov Date: Tue, 31 Aug 2021 17:19:21 +0300 Subject: [PATCH] feat(revwalk): add bindings and api --- lib/libgit2dart.dart | 1 + lib/src/bindings/revwalk.dart | 136 ++++++++++++++++++++++++++++++++ lib/src/enums.dart | 16 ++++ lib/src/repository.dart | 18 +++++ lib/src/revwalk.dart | 89 +++++++++++++++++++++ test/repository_test.dart | 20 +++++ test/revwalk_test.dart | 144 ++++++++++++++++++++++++++++++++++ 7 files changed, 424 insertions(+) create mode 100644 lib/src/bindings/revwalk.dart create mode 100644 lib/src/revwalk.dart create mode 100644 test/revwalk_test.dart diff --git a/lib/libgit2dart.dart b/lib/libgit2dart.dart index 84dbb7b..dae8dc2 100644 --- a/lib/libgit2dart.dart +++ b/lib/libgit2dart.dart @@ -8,5 +8,6 @@ export 'src/reference.dart'; export 'src/reflog.dart'; export 'src/tree.dart'; export 'src/signature.dart'; +export 'src/revwalk.dart'; export 'src/error.dart'; export 'src/enums.dart'; diff --git a/lib/src/bindings/revwalk.dart b/lib/src/bindings/revwalk.dart new file mode 100644 index 0000000..1584021 --- /dev/null +++ b/lib/src/bindings/revwalk.dart @@ -0,0 +1,136 @@ +import 'dart:ffi'; +import 'package:ffi/ffi.dart'; +import 'libgit2_bindings.dart'; +import 'commit.dart' as commit_bindings; +import '../error.dart'; +import '../util.dart'; + +/// Allocate a new revision walker to iterate through a repo. +/// +/// This revision walker uses a custom memory pool and an internal commit cache, +/// so it is relatively expensive to allocate. +/// +/// For maximum performance, this revision walker should be reused for different walks. +/// +/// This revision walker is not thread safe: it may only be used to walk a repository +/// on a single thread; however, it is possible to have several revision walkers in several +/// different threads walking the same repository. +/// +/// Throws a [LibGit2Error] if error occured. +Pointer create(Pointer repo) { + final out = calloc>(); + final error = libgit2.git_revwalk_new(out, repo); + + if (error < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } else { + return out.value; + } +} + +/// Change the sorting mode when iterating through the repository's contents. +/// +/// Changing the sorting mode resets the walker. +/// +/// Throws a [LibGit2Error] if error occured. +void sorting(Pointer walker, int sortMode) { + final error = libgit2.git_revwalk_sorting(walker, sortMode); + + if (error < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } +} + +/// Add a new root for the traversal. +/// +/// The pushed commit will be marked as one of the roots from which to start the walk. +/// This commit may not be walked if it or a child is hidden. +/// +/// At least one commit must be pushed onto the walker before a walk can be started. +/// +/// The given id must belong to a committish on the walked repository. +/// +/// Throws a [LibGit2Error] if error occured. +void push(Pointer walker, Pointer id) { + final error = libgit2.git_revwalk_push(walker, id); + + if (error < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } +} + +/// Get the list of commits from the revision walk. +/// +/// The initial call to this method is not blocking when iterating through a repo +/// with a time-sorting mode. +/// +/// Iterating with Topological or inverted modes makes the initial call blocking to +/// preprocess the commit list, but this block should be mostly unnoticeable on most +/// repositories (topological preprocessing times at 0.3s on the git.git repo). +/// +/// The revision walker is reset when the walk is over. +List> walk( + Pointer repo, + Pointer walker, +) { + var result = >[]; + var error = 0; + + while (error == 0) { + final oid = calloc(); + error = libgit2.git_revwalk_next(oid, walker); + if (error == 0) { + final commit = commit_bindings.lookup(repo, oid); + result.add(commit); + calloc.free(oid); + } else { + break; + } + } + + return result; +} + +/// Mark a commit (and its ancestors) uninteresting for the output. +/// +/// The given id must belong to a committish on the walked repository. +/// +/// The resolved commit and all its parents will be hidden from the output on the revision walk. +/// +/// Throws a [LibGit2Error] if error occured. +void hide(Pointer walk, Pointer oid) { + final error = libgit2.git_revwalk_hide(walk, oid); + + if (error < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } +} + +/// Reset the revision walker for reuse. +/// +/// This will clear all the pushed and hidden commits, and leave the walker in a blank state +/// (just like at creation) ready to receive new commit pushes and start a new walk. +/// +/// The revision walk is automatically reset when a walk is over. +void reset(Pointer walker) => libgit2.git_revwalk_reset(walker); + +/// Simplify the history by first-parent. +/// +/// No parents other than the first for each commit will be enqueued. +/// +/// Throws a [LibGit2Error] if error occured. +void simplifyFirstParent(Pointer walk) { + final error = libgit2.git_revwalk_simplify_first_parent(walk); + + if (error < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } +} + +/// Return the repository on which this walker is operating. +Pointer repository(Pointer walker) { + return libgit2.git_revwalk_repository(walker); +} + +/// Free a revision walker previously allocated. +void free(Pointer walk) => libgit2.git_revwalk_free(walk); diff --git a/lib/src/enums.dart b/lib/src/enums.dart index 13954ae..185662e 100644 --- a/lib/src/enums.dart +++ b/lib/src/enums.dart @@ -1,3 +1,19 @@ enum ReferenceType { direct, symbolic } enum GitFilemode { undreadable, tree, blob, blobExecutable, link, commit } + +/// Flags to specify the sorting which a revwalk should perform. +/// +/// [none] sort the output with the same default method from `git`: reverse +/// chronological order. This is the default sorting for new walkers. +/// +/// [topological] sort the repository contents in topological order (no parents before +/// all of its children are shown); this sorting mode can be combined +/// with time sorting to produce `git`'s `--date-order``. +/// +/// [time] sort the repository contents by commit time; +/// this sorting mode can be combined with topological sorting. +/// +/// [reverse] Iterate through the repository contents in reverse +/// order; this sorting mode can be combined with any of the above. +enum GitSort { none, topological, time, reverse } diff --git a/lib/src/repository.dart b/lib/src/repository.dart index d36f11b..e0abd5e 100644 --- a/lib/src/repository.dart +++ b/lib/src/repository.dart @@ -1,4 +1,7 @@ import 'dart:ffi'; +import 'package:libgit2dart/src/enums.dart'; +import 'package:libgit2dart/src/revwalk.dart'; + import 'commit.dart'; import 'config.dart'; import 'index.dart'; @@ -320,4 +323,19 @@ class Repository { } return Commit.lookup(this, oid); } + + /// Returns the list of commits starting from provided [oid]. + /// + /// If [sorting] isn't provided default will be used (reverse chronological order, like in git). + List log(Oid oid, [GitSort sorting = GitSort.none]) { + final walker = RevWalk(this); + if (sorting != GitSort.none) { + walker.sorting(sorting); + } + walker.push(oid); + final result = walker.walk(); + walker.free(); + + return result; + } } diff --git a/lib/src/revwalk.dart b/lib/src/revwalk.dart new file mode 100644 index 0000000..e511e35 --- /dev/null +++ b/lib/src/revwalk.dart @@ -0,0 +1,89 @@ +import 'dart:ffi'; +import 'bindings/libgit2_bindings.dart'; +import 'bindings/revwalk.dart' as bindings; +import 'commit.dart'; +import 'oid.dart'; +import 'repository.dart'; +import 'enums.dart'; +import 'util.dart'; + +class RevWalk { + /// Initializes a new instance of the [RevWalk] class. + /// Should be freed with `free()` to release allocated memory. + RevWalk(Repository repo) { + libgit2.git_libgit2_init(); + _revWalkPointer = bindings.create(repo.pointer); + } + + /// Pointer to memory address for allocated [RevWalk] object. + late final Pointer _revWalkPointer; + + /// Returns the list of commits from the revision walk. + /// + /// Default sorting is reverse chronological order (default in git). + List walk() { + final repoPointer = bindings.repository(_revWalkPointer); + var result = []; + + final commits = bindings.walk(repoPointer, _revWalkPointer); + for (var commit in commits) { + result.add(Commit(commit)); + } + + return result; + } + + /// Changes the sorting mode when iterating through the repository's contents. + /// + /// Changing the sorting mode resets the walker. + /// + /// Throws a [LibGit2Error] if error occured. + void sorting(GitSort sorting) { + bindings.sorting( + _revWalkPointer, + // in libgit2 GIT_SORT_REVERSE flag is integer 4 so we are adding 1 to our enum index + sorting == GitSort.reverse ? sorting.index + 1 : sorting.index, + ); + } + + /// Adds a new root for the traversal. + /// + /// The pushed commit will be marked as one of the roots from which to start the walk. + /// This commit may not be walked if it or a child is hidden. + /// + /// At least one commit must be pushed onto the walker before a walk can be started. + /// + /// The given id must belong to a committish on the walked repository. + /// + /// Throws a [LibGit2Error] if error occured. + void push(Oid oid) => bindings.push(_revWalkPointer, oid.pointer); + + /// Marks a commit (and its ancestors) uninteresting for the output. + /// + /// The given id must belong to a committish on the walked repository. + /// + /// The resolved commit and all its parents will be hidden from the output on the revision walk. + /// + /// Throws a [LibGit2Error] if error occured. + void hide(Oid oid) => bindings.hide(_revWalkPointer, oid.pointer); + + /// Resets the revision walker for reuse. + /// + /// This will clear all the pushed and hidden commits, and leave the walker in a blank state + /// (just like at creation) ready to receive new commit pushes and start a new walk. + /// + /// The revision walk is automatically reset when a walk is over. + void reset() => bindings.reset(_revWalkPointer); + + /// Simplify the history by first-parent. + /// + /// No parents other than the first for each commit will be enqueued. + /// + /// Throws a [LibGit2Error] if error occured. + void simplifyFirstParent() => bindings.simplifyFirstParent(_revWalkPointer); + + /// Releases memory allocated for [RevWalk] object. + void free() { + bindings.free(_revWalkPointer); + } +} diff --git a/test/repository_test.dart b/test/repository_test.dart index e43384e..c4ba14a 100644 --- a/test/repository_test.dart +++ b/test/repository_test.dart @@ -179,6 +179,26 @@ void main() { config.free(); }); + test('returns list of commits by walking from provided starting oid', () { + const log = [ + '78b8bf123e3952c970ae5c1ce0a3ea1d1336f6e8', + 'c68ff54aabf660fcdd9a2838d401583fe31249e3', + 'fc38877b2552ab554752d9a77e1f48f738cca79b', + '6cbc22e509d72758ab4c8d9f287ea846b90c448b', + 'f17d0d48eae3aa08cecf29128a35e310c97b3521', + ]; + final start = Oid.fromSHA(repo, lastCommit); + final commits = repo.log(start); + + for (var i = 0; i < commits.length; i++) { + expect(commits[i].id.sha, log[i]); + } + + for (var c in commits) { + c.free(); + } + }); + group('.discover()', () { test('discovers repository', () async { final subDir = '${tmpDir}subdir1/subdir2/'; diff --git a/test/revwalk_test.dart b/test/revwalk_test.dart new file mode 100644 index 0000000..ddbbbd3 --- /dev/null +++ b/test/revwalk_test.dart @@ -0,0 +1,144 @@ +import 'dart:io'; + +import 'package:test/test.dart'; +import 'package:libgit2dart/libgit2dart.dart'; +import 'helpers/util.dart'; + +void main() { + const log = [ + '78b8bf123e3952c970ae5c1ce0a3ea1d1336f6e8', + 'c68ff54aabf660fcdd9a2838d401583fe31249e3', + 'fc38877b2552ab554752d9a77e1f48f738cca79b', + '6cbc22e509d72758ab4c8d9f287ea846b90c448b', + 'f17d0d48eae3aa08cecf29128a35e310c97b3521', + ]; + late Repository repo; + final tmpDir = '${Directory.systemTemp.path}/revwalk_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('RevWalk', () { + test('returns list of commits with default sorting', () { + final walker = RevWalk(repo); + final start = Oid.fromSHA(repo, log.first); + + walker.push(start); + final commits = walker.walk(); + + for (var i = 0; i < commits.length; i++) { + expect(commits[i].id.sha, log[i]); + } + + for (var c in commits) { + c.free(); + } + walker.free(); + }); + + test('returns list of commits with reverse sorting', () { + final walker = RevWalk(repo); + final start = Oid.fromSHA(repo, log.first); + + walker.push(start); + walker.sorting(GitSort.reverse); + final commits = walker.walk(); + + for (var i = 0; i < commits.length; i++) { + expect(commits[i].id.sha, log.reversed.toList()[i]); + } + + for (var c in commits) { + c.free(); + } + walker.free(); + }); + + test('successfully changes sorting', () { + final walker = RevWalk(repo); + final start = Oid.fromSHA(repo, log.first); + + walker.push(start); + final timeSortedCommits = walker.walk(); + + for (var i = 0; i < timeSortedCommits.length; i++) { + expect(timeSortedCommits[i].id.sha, log[i]); + } + + walker.sorting(GitSort.reverse); + final reverseSortedCommits = walker.walk(); + for (var i = 0; i < reverseSortedCommits.length; i++) { + expect(reverseSortedCommits[i].id.sha, log.reversed.toList()[i]); + } + + for (var c in timeSortedCommits) { + c.free(); + } + for (var c in reverseSortedCommits) { + c.free(); + } + walker.free(); + }); + + test('successfully hides commit and its ancestors', () { + final walker = RevWalk(repo); + final start = Oid.fromSHA(repo, log.first); + final oidToHide = Oid.fromSHA(repo, log[2]); + + walker.push(start); + walker.hide(oidToHide); + final commits = walker.walk(); + + expect(commits.length, 2); + + for (var c in commits) { + c.free(); + } + walker.free(); + }); + + test('successfully resets walker', () { + final walker = RevWalk(repo); + final start = Oid.fromSHA(repo, log.first); + + walker.push(start); + walker.reset(); + final commits = walker.walk(); + + expect(commits, []); + + walker.free(); + }); + + test('simplifies walker by enqueuing only first parent for each commit', + () { + final walker = RevWalk(repo); + final start = Oid.fromSHA(repo, log.first); + + walker.push(start); + walker.simplifyFirstParent(); + final commits = walker.walk(); + + for (var i = 0; i < commits.length; i++) { + expect(commits.length, 3); + } + + for (var c in commits) { + c.free(); + } + walker.free(); + }); + }); +}