diff --git a/lib/src/bindings/checkout.dart b/lib/src/bindings/checkout.dart new file mode 100644 index 0000000..a52012d --- /dev/null +++ b/lib/src/bindings/checkout.dart @@ -0,0 +1,127 @@ +import 'dart:ffi'; +import 'package:ffi/ffi.dart'; +import '../error.dart'; +import 'libgit2_bindings.dart'; +import '../util.dart'; + +/// Updates files in the index and the working tree to match the content of the commit +/// pointed at by HEAD. +/// +/// Note that this is not the correct mechanism used to switch branches; do not change +/// your HEAD and then call this method, that would leave you with checkout conflicts +/// since your working directory would then appear to be dirty. Instead, checkout the +/// target of the branch and then update HEAD using `setHead` to point to the branch you checked out. +/// +/// Throws a [LibGit2Error] if error occured. +void head( + Pointer repo, + int strategy, + String? directory, + List? paths, +) { + final initOptions = _initOptions(strategy, directory, paths); + final optsC = initOptions[0]; + final pathPointers = initOptions[1]; + final strArray = initOptions[2]; + + final error = libgit2.git_checkout_head(repo, optsC); + + for (var p in pathPointers) { + calloc.free(p); + } + + calloc.free(strArray); + calloc.free(optsC); + + if (error < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } +} + +/// Updates files in the working tree to match the content of the index. +/// +/// Throws a [LibGit2Error] if error occured. +void index( + Pointer repo, + int strategy, + String? directory, + List? paths, +) { + final initOptions = _initOptions(strategy, directory, paths); + final optsC = initOptions[0]; + final pathPointers = initOptions[1]; + final strArray = initOptions[2]; + + final error = libgit2.git_checkout_index(repo, nullptr, optsC); + + for (var p in pathPointers) { + calloc.free(p); + } + + calloc.free(strArray); + calloc.free(optsC); + + if (error < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } +} + +/// Updates files in the index and working tree to match the content of the tree +/// pointed at by the treeish. +/// +/// Throws a [LibGit2Error] if error occured. +void tree( + Pointer repo, + Pointer treeish, + int strategy, + String? directory, + List? paths, +) { + final initOptions = _initOptions(strategy, directory, paths); + final optsC = initOptions[0]; + final pathPointers = initOptions[1]; + final strArray = initOptions[2]; + + final error = libgit2.git_checkout_tree(repo, treeish, optsC); + + for (var p in pathPointers) { + calloc.free(p); + } + + calloc.free(strArray); + calloc.free(optsC); + + if (error < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } +} + +List _initOptions( + int strategy, + String? directory, + List? paths, +) { + final optsC = calloc(sizeOf()); + libgit2.git_checkout_options_init(optsC, GIT_CHECKOUT_OPTIONS_VERSION); + optsC.ref.checkout_strategy = strategy; + if (directory != null) { + optsC.ref.target_directory = directory.toNativeUtf8().cast(); + } + List> pathPointers = []; + Pointer> strArray = nullptr; + if (paths != null) { + pathPointers = paths.map((e) => e.toNativeUtf8().cast()).toList(); + strArray = calloc(paths.length); + for (var i = 0; i < paths.length; i++) { + strArray[i] = pathPointers[i]; + } + optsC.ref.paths.strings = strArray; + optsC.ref.paths.count = paths.length; + } + + var result = []; + result.add(optsC); + result.add(pathPointers); + result.add(strArray); + return result; +} diff --git a/lib/src/git_types.dart b/lib/src/git_types.dart index b241f76..028a66f 100644 --- a/lib/src/git_types.dart +++ b/lib/src/git_types.dart @@ -309,3 +309,84 @@ class GitMergeFileFlag { int get value => _value; } + +/// Checkout behavior flags. +/// +/// In libgit2, checkout is used to update the working directory and index +/// to match a target tree. Unlike git checkout, it does not move the HEAD +/// commit for you - use `setHead` or the like to do that. +class GitCheckout { + const GitCheckout._(this._value); + final int _value; + + /// Default is a dry run, no actual updates. + static const none = GitCheckout._(0); + + /// Allow safe updates that cannot overwrite uncommitted data. + /// If the uncommitted changes don't conflict with the checked out files, + /// the checkout will still proceed, leaving the changes intact. + /// + /// Mutually exclusive with [GitCheckout.force]. + /// [GitCheckout.force] takes precedence over [GitCheckout.safe]. + static const safe = GitCheckout._(1); + + /// Allow all updates to force working directory to look like index. + /// + /// Mutually exclusive with [GitCheckout.safe]. + /// [GitCheckout.force] takes precedence over [GitCheckout.safe]. + static const force = GitCheckout._(2); + + /// Allow checkout to recreate missing files. + static const recreateMissing = GitCheckout._(4); + + /// Allow checkout to make safe updates even if conflicts are found. + static const allowConflicts = GitCheckout._(16); + + /// Remove untracked files not in index (that are not ignored). + static const removeUntracked = GitCheckout._(32); + + /// Remove ignored files not in index. + static const removeIgnored = GitCheckout._(64); + + /// Only update existing files, don't create new ones. + static const updateOnly = GitCheckout._(128); + + /// Normally checkout updates index entries as it goes; this stops that. + /// Implies [GitCheckout.dontWriteIndex]. + static const dontUpdateIndex = GitCheckout._(256); + + /// Don't refresh index/config/etc before doing checkout. + static const noRefresh = GitCheckout._(512); + + /// Allow checkout to skip unmerged files. + static const skipUnmerged = GitCheckout._(1024); + + /// For unmerged files, checkout stage 2 from index. + static const useOurs = GitCheckout._(2048); + + /// For unmerged files, checkout stage 3 from index. + static const useTheirs = GitCheckout._(4096); + + /// Treat pathspec as simple list of exact match file paths. + static const disablePathspecMatch = GitCheckout._(8192); + + /// Ignore directories in use, they will be left empty. + static const skipLockedDirectories = GitCheckout._(262144); + + /// Don't overwrite ignored files that exist in the checkout target. + static const dontOverwriteIgnored = GitCheckout._(524288); + + /// Write normal merge files for conflicts. + static const conflictStyleMerge = GitCheckout._(1048576); + + /// Include common ancestor data in diff3 format files for conflicts. + static const conflictStyleDiff3 = GitCheckout._(2097152); + + /// Don't overwrite existing files or folders. + static const dontRemoveExisting = GitCheckout._(4194304); + + /// Normally checkout writes the index upon completion; this prevents that. + static const dontWriteIndex = GitCheckout._(8388608); + + int get value => _value; +} diff --git a/lib/src/repository.dart b/lib/src/repository.dart index b36180a..8624bc0 100644 --- a/lib/src/repository.dart +++ b/lib/src/repository.dart @@ -7,6 +7,7 @@ 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 'bindings/checkout.dart' as checkout_bindings; import 'branch.dart'; import 'commit.dart'; import 'config.dart'; @@ -459,13 +460,18 @@ class Repository { var count = status_bindings.listEntryCount(list); for (var i = 0; i < count; i++) { + late String path; final entry = status_bindings.getByIndex(list, i); if (entry.ref.head_to_index != nullptr) { - final path = entry.ref.head_to_index.ref.old_file.path + path = entry.ref.head_to_index.ref.old_file.path + .cast() + .toDartString(); + } else { + path = entry.ref.index_to_workdir.ref.old_file.path .cast() .toDartString(); - result[path] = entry.ref.status; } + result[path] = entry.ref.status; } status_bindings.listFree(list); @@ -535,7 +541,7 @@ class Repository { commit_bindings.annotatedFree(theirHead.value); } - /// Merges two commits, producing a git_index that reflects the result of the merge. + /// Merges two commits, producing an index that reflects the result of the merge. /// The index may be written as-is to the working directory or checked out. If the index /// is to be converted to a tree, the caller should resolve any conflicts that arose as /// part of the merge. @@ -571,7 +577,7 @@ class Repository { return Index(result); } - /// Merge two trees, producing a git_index that reflects the result of the merge. + /// Merges two trees, producing an index that reflects the result of the merge. /// The index may be written as-is to the working directory or checked out. If the index /// is to be converted to a tree, the caller should resolve any conflicts that arose as part /// of the merge. @@ -609,7 +615,7 @@ class Repository { return Index(result); } - /// Cherry-picks the given commit, producing changes in the index and working directory. + /// Cherry-picks the provided commit, producing changes in the index and working directory. /// /// Any changes are staged for commit and any conflicts are written to the index. Callers /// should inspect the repository's index after this completes, resolve any conflicts and @@ -618,4 +624,47 @@ class Repository { /// Throws a [LibGit2Error] if error occured. void cherryPick(Commit commit) => merge_bindings.cherryPick(_repoPointer, commit.pointer); + + /// Checkouts the provided reference [refName] using the given strategy, and update the HEAD. + /// + /// If no reference [refName] is given, checkouts from the index. + /// + /// Default checkout strategy is combination of [GitCheckout.safe] and + /// [GitCheckout.recreateMissing]. + /// + /// [directory] is alternative checkout path to workdir. + /// + /// [paths] is list of files to checkout from provided reference [refName]. If paths are provided + /// HEAD will not be set to the reference [refName]. + void checkout({ + String refName = '', + List strategy = const [ + GitCheckout.safe, + GitCheckout.recreateMissing + ], + String? directory, + List? paths, + }) { + final int strat = strategy.fold( + 0, + (previousValue, element) => previousValue + element.value, + ); + + if (refName.isEmpty) { + checkout_bindings.index(_repoPointer, strat, directory, paths); + } else if (refName == 'HEAD') { + checkout_bindings.head(_repoPointer, strat, directory, paths); + } else { + final ref = references[refName]; + final treeish = object_bindings.lookup( + _repoPointer, ref.target.pointer, GitObject.any.value); + checkout_bindings.tree(_repoPointer, treeish, strat, directory, paths); + if (paths == null) { + setHead(refName); + } + + object_bindings.free(treeish); + ref.free(); + } + } } diff --git a/test/checkout_test.dart b/test/checkout_test.dart new file mode 100644 index 0000000..37a0553 --- /dev/null +++ b/test/checkout_test.dart @@ -0,0 +1,96 @@ +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; + final tmpDir = '${Directory.systemTemp.path}/checkout_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('Checkout', () { + test('successfully checkouts head', () { + File('${tmpDir}feature_file').writeAsStringSync('edit'); + expect(repo.status, contains('feature_file')); + + repo.checkout(refName: 'HEAD', strategy: [GitCheckout.force]); + expect(repo.status, isEmpty); + }); + + test('successfully checkouts index', () { + File('${repo.workdir}feature_file').writeAsStringSync('edit'); + expect(repo.status, contains('feature_file')); + + repo.checkout(strategy: [GitCheckout.force]); + expect(repo.status, isEmpty); + }); + + test('successfully checkouts tree', () { + final masterHead = + repo['821ed6e80627b8769d170a293862f9fc60825226'] as Commit; + final masterTree = repo[masterHead.tree.sha] as Tree; + expect( + masterTree.entries.any((e) => e.name == 'another_feature_file'), + false, + ); + + repo.checkout(refName: 'refs/heads/feature'); + final featureHead = + repo['5aecfa0fb97eadaac050ccb99f03c3fb65460ad4'] as Commit; + final featureTree = repo[featureHead.tree.sha] as Tree; + final repoHead = repo.head; + expect(repoHead.target.sha, featureHead.id.sha); + expect(repo.status, isEmpty); + expect( + featureTree.entries.any((e) => e.name == 'another_feature_file'), + true, + ); + + repoHead.free(); + featureTree.free(); + featureHead.free(); + masterTree.free(); + masterHead.free(); + }); + + test('successfully checkouts with alrenative directory', () { + final altDir = '${Directory.systemTemp.path}/alt_dir'; + // making sure there is no directory + if (Directory(altDir).existsSync()) { + Directory(altDir).deleteSync(recursive: true); + } + Directory(altDir).createSync(); + expect(Directory(altDir).listSync().length, 0); + + repo.checkout(refName: 'refs/heads/feature', directory: altDir); + expect(Directory(altDir).listSync().length, isNot(0)); + + Directory(altDir).deleteSync(recursive: true); + }); + + test('successfully checkouts file with provided path', () { + expect(repo.status, isEmpty); + repo.checkout( + refName: 'refs/heads/feature', + paths: ['another_feature_file'], + ); + expect(repo.status, {'another_feature_file': GitStatus.indexNew.value}); + }); + }); +}