From a7b714c2f34e2fda205f651593ca598d99dbe27e Mon Sep 17 00:00:00 2001 From: Aleksey Kulikov Date: Tue, 14 Sep 2021 19:55:25 +0300 Subject: [PATCH] feat(diff): add bindings and api --- lib/libgit2dart.dart | 1 + lib/src/bindings/diff.dart | 277 ++++++++++++ lib/src/diff.dart | 213 ++++++++++ lib/src/git_types.dart | 402 ++++++++++++++++++ lib/src/index.dart | 54 ++- lib/src/tree.dart | 70 +++ test/assets/dirtyrepo/.gitdir/COMMIT_EDITMSG | 1 + test/assets/dirtyrepo/.gitdir/HEAD | 1 + .../dirtyrepo/.gitdir/branches/empty_marker | 0 test/assets/dirtyrepo/.gitdir/config | 5 + test/assets/dirtyrepo/.gitdir/description | 1 + .../.gitdir/hooks/applypatch-msg.sample | 15 + .../dirtyrepo/.gitdir/hooks/commit-msg.sample | 24 ++ .../.gitdir/hooks/post-commit.sample | 8 + .../.gitdir/hooks/post-receive.sample | 15 + .../.gitdir/hooks/post-update.sample | 8 + .../.gitdir/hooks/pre-applypatch.sample | 14 + .../dirtyrepo/.gitdir/hooks/pre-commit.sample | 46 ++ .../dirtyrepo/.gitdir/hooks/pre-rebase.sample | 172 ++++++++ .../.gitdir/hooks/prepare-commit-msg.sample | 36 ++ .../dirtyrepo/.gitdir/hooks/update.sample | 128 ++++++ test/assets/dirtyrepo/.gitdir/index | Bin 0 -> 1064 bytes test/assets/dirtyrepo/.gitdir/info/exclude | 6 + test/assets/dirtyrepo/.gitdir/logs/HEAD | 1 + .../dirtyrepo/.gitdir/logs/refs/heads/master | 1 + .../a7/63aa560953e7cfb87ccbc2f536d665aa4dff22 | 2 + .../b8/5d53c9236e89aff2b62558adaa885fd1d6ff1c | Bin 0 -> 78 bytes .../c2/17c63469eca3538ca896a55f1990121a909f9e | Bin 0 -> 33 bytes .../e3/728acced34e063a6e5830c52659f7fdb7a7858 | Bin 0 -> 154 bytes .../e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 | Bin 0 -> 15 bytes .../.gitdir/objects/info/empty_marker | 0 .../.gitdir/objects/pack/empty_marker | 0 .../dirtyrepo/.gitdir/refs/heads/master | 1 + .../dirtyrepo/.gitdir/refs/tags/empty_marker | 0 test/assets/dirtyrepo/current_file | 0 test/assets/dirtyrepo/modified_file | 1 + test/assets/dirtyrepo/new_file | 0 test/assets/dirtyrepo/staged_changes | 1 + .../dirtyrepo/staged_changes_file_modified | 1 + .../dirtyrepo/staged_delete_file_modified | 1 + test/assets/dirtyrepo/staged_new | 0 .../assets/dirtyrepo/staged_new_file_modified | 1 + test/assets/dirtyrepo/subdir/current_file | 0 test/assets/dirtyrepo/subdir/modified_file | 1 + test/assets/dirtyrepo/subdir/new_file | 0 test/diff_test.dart | 271 ++++++++++++ test/reset_test.dart | 14 +- 47 files changed, 1789 insertions(+), 4 deletions(-) create mode 100644 lib/src/bindings/diff.dart create mode 100644 lib/src/diff.dart create mode 100644 test/assets/dirtyrepo/.gitdir/COMMIT_EDITMSG create mode 100644 test/assets/dirtyrepo/.gitdir/HEAD create mode 100644 test/assets/dirtyrepo/.gitdir/branches/empty_marker create mode 100644 test/assets/dirtyrepo/.gitdir/config create mode 100644 test/assets/dirtyrepo/.gitdir/description create mode 100755 test/assets/dirtyrepo/.gitdir/hooks/applypatch-msg.sample create mode 100755 test/assets/dirtyrepo/.gitdir/hooks/commit-msg.sample create mode 100755 test/assets/dirtyrepo/.gitdir/hooks/post-commit.sample create mode 100755 test/assets/dirtyrepo/.gitdir/hooks/post-receive.sample create mode 100755 test/assets/dirtyrepo/.gitdir/hooks/post-update.sample create mode 100755 test/assets/dirtyrepo/.gitdir/hooks/pre-applypatch.sample create mode 100755 test/assets/dirtyrepo/.gitdir/hooks/pre-commit.sample create mode 100755 test/assets/dirtyrepo/.gitdir/hooks/pre-rebase.sample create mode 100755 test/assets/dirtyrepo/.gitdir/hooks/prepare-commit-msg.sample create mode 100755 test/assets/dirtyrepo/.gitdir/hooks/update.sample create mode 100644 test/assets/dirtyrepo/.gitdir/index create mode 100644 test/assets/dirtyrepo/.gitdir/info/exclude create mode 100644 test/assets/dirtyrepo/.gitdir/logs/HEAD create mode 100644 test/assets/dirtyrepo/.gitdir/logs/refs/heads/master create mode 100644 test/assets/dirtyrepo/.gitdir/objects/a7/63aa560953e7cfb87ccbc2f536d665aa4dff22 create mode 100644 test/assets/dirtyrepo/.gitdir/objects/b8/5d53c9236e89aff2b62558adaa885fd1d6ff1c create mode 100644 test/assets/dirtyrepo/.gitdir/objects/c2/17c63469eca3538ca896a55f1990121a909f9e create mode 100644 test/assets/dirtyrepo/.gitdir/objects/e3/728acced34e063a6e5830c52659f7fdb7a7858 create mode 100644 test/assets/dirtyrepo/.gitdir/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 create mode 100644 test/assets/dirtyrepo/.gitdir/objects/info/empty_marker create mode 100644 test/assets/dirtyrepo/.gitdir/objects/pack/empty_marker create mode 100644 test/assets/dirtyrepo/.gitdir/refs/heads/master create mode 100644 test/assets/dirtyrepo/.gitdir/refs/tags/empty_marker create mode 100644 test/assets/dirtyrepo/current_file create mode 100644 test/assets/dirtyrepo/modified_file create mode 100644 test/assets/dirtyrepo/new_file create mode 100644 test/assets/dirtyrepo/staged_changes create mode 100644 test/assets/dirtyrepo/staged_changes_file_modified create mode 100644 test/assets/dirtyrepo/staged_delete_file_modified create mode 100644 test/assets/dirtyrepo/staged_new create mode 100644 test/assets/dirtyrepo/staged_new_file_modified create mode 100644 test/assets/dirtyrepo/subdir/current_file create mode 100644 test/assets/dirtyrepo/subdir/modified_file create mode 100644 test/assets/dirtyrepo/subdir/new_file create mode 100644 test/diff_test.dart diff --git a/lib/libgit2dart.dart b/lib/libgit2dart.dart index ffc5d20..87482b1 100644 --- a/lib/libgit2dart.dart +++ b/lib/libgit2dart.dart @@ -14,5 +14,6 @@ export 'src/tag.dart'; export 'src/treebuilder.dart'; export 'src/branch.dart'; export 'src/worktree.dart'; +export 'src/diff.dart'; export 'src/error.dart'; export 'src/git_types.dart'; diff --git a/lib/src/bindings/diff.dart b/lib/src/bindings/diff.dart new file mode 100644 index 0000000..da8f34f --- /dev/null +++ b/lib/src/bindings/diff.dart @@ -0,0 +1,277 @@ +import 'dart:ffi'; +import 'package:ffi/ffi.dart'; +import '../error.dart'; +import 'libgit2_bindings.dart'; +import '../util.dart'; + +/// Create a diff between the repository index and the workdir directory. +/// +/// Throws a [LibGit2Error] if error occured. +Pointer indexToWorkdir( + Pointer repo, + Pointer index, + int flags, + int contextLines, + int interhunkLines, +) { + final out = calloc>(); + final opts = calloc(); + final optsError = + libgit2.git_diff_options_init(opts, GIT_DIFF_OPTIONS_VERSION); + opts.ref.flags = flags; + opts.ref.context_lines = contextLines; + opts.ref.interhunk_lines = interhunkLines; + + if (optsError < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } + + libgit2.git_diff_index_to_workdir(out, repo, index, opts); + + calloc.free(opts); + + return out.value; +} + +/// Create a diff between a tree and repository index. +/// +/// Throws a [LibGit2Error] if error occured. +Pointer treeToIndex( + Pointer repo, + Pointer oldTree, + Pointer index, + int flags, + int contextLines, + int interhunkLines, +) { + final out = calloc>(); + final opts = calloc(); + final optsError = + libgit2.git_diff_options_init(opts, GIT_DIFF_OPTIONS_VERSION); + opts.ref.flags = flags; + opts.ref.context_lines = contextLines; + opts.ref.interhunk_lines = interhunkLines; + + if (optsError < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } + + libgit2.git_diff_tree_to_index(out, repo, oldTree, index, opts); + + calloc.free(opts); + + return out.value; +} + +/// Create a diff between a tree and the working directory. +/// +/// Throws a [LibGit2Error] if error occured. +Pointer treeToWorkdir( + Pointer repo, + Pointer oldTree, + int flags, + int contextLines, + int interhunkLines, +) { + final out = calloc>(); + final opts = calloc(); + final optsError = + libgit2.git_diff_options_init(opts, GIT_DIFF_OPTIONS_VERSION); + opts.ref.flags = flags; + opts.ref.context_lines = contextLines; + opts.ref.interhunk_lines = interhunkLines; + + if (optsError < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } + + libgit2.git_diff_tree_to_workdir(out, repo, oldTree, opts); + + calloc.free(opts); + + return out.value; +} + +/// Create a diff with the difference between two tree objects. +/// +/// Throws a [LibGit2Error] if error occured. +Pointer treeToTree( + Pointer repo, + Pointer oldTree, + Pointer newTree, + int flags, + int contextLines, + int interhunkLines, +) { + final out = calloc>(); + final opts = calloc(); + final optsError = + libgit2.git_diff_options_init(opts, GIT_DIFF_OPTIONS_VERSION); + opts.ref.flags = flags; + opts.ref.context_lines = contextLines; + opts.ref.interhunk_lines = interhunkLines; + + if (optsError < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } + + libgit2.git_diff_tree_to_tree(out, repo, oldTree, newTree, opts); + + calloc.free(opts); + + return out.value; +} + +/// Query how many diff records are there in a diff. +int length(Pointer diff) => libgit2.git_diff_num_deltas(diff); + +/// Merge one diff into another. +/// +/// This merges items from the "from" list into the "onto" list. The resulting diff +/// will have all items that appear in either list. If an item appears in both lists, +/// then it will be "merged" to appear as if the old version was from the "onto" list +/// and the new version is from the "from" list (with the exception that if the item +/// has a pending DELETE in the middle, then it will show as deleted). +void merge(Pointer onto, Pointer from) { + libgit2.git_diff_merge(onto, from); +} + +/// Read the contents of a git patch file into a git diff object. +/// +/// The diff object produced is similar to the one that would be produced if you actually +/// produced it computationally by comparing two trees, however there may be subtle differences. +/// For example, a patch file likely contains abbreviated object IDs, so the object IDs in a +/// diff delta produced by this function will also be abbreviated. +/// +/// This function will only read patch files created by a git implementation, it will not +/// read unified diffs produced by the `diff` program, nor any other types of patch files. +/// +/// Throws a [LibGit2Error] if error occured. +Pointer parse(String content) { + final out = calloc>(); + final contentC = content.toNativeUtf8().cast(); + final error = libgit2.git_diff_from_buffer(out, contentC, content.length); + + calloc.free(contentC); + + if (error < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } else { + return out.value; + } +} + +/// Transform a diff marking file renames, copies, etc. +/// +/// This modifies a diff in place, replacing old entries that look like renames or copies +/// with new entries reflecting those changes. This also will, if requested, break modified +/// files into add/remove pairs if the amount of change is above a threshold. +/// +/// Throws a [LibGit2Error] if error occured. +void findSimilar( + Pointer diff, + int flags, + int renameThreshold, + int copyThreshold, + int renameFromRewriteThreshold, + int breakRewriteThreshold, + int renameLimit) { + final opts = calloc(); + final optsError = + libgit2.git_diff_find_options_init(opts, GIT_DIFF_FIND_OPTIONS_VERSION); + opts.ref.flags = flags; + opts.ref.rename_threshold = renameThreshold; + opts.ref.copy_threshold = copyThreshold; + opts.ref.rename_from_rewrite_threshold = renameFromRewriteThreshold; + opts.ref.break_rewrite_threshold = breakRewriteThreshold; + opts.ref.rename_limit = renameLimit; + + if (optsError < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } + + final error = libgit2.git_diff_find_similar(diff, opts); + + if (error < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } + + calloc.free(opts); +} + +/// Return the diff delta for an entry in the diff list. +/// +/// Throws [RangeError] if index out of range. +Pointer getDeltaByIndex(Pointer diff, int idx) { + final result = libgit2.git_diff_get_delta(diff, idx); + + if (result == nullptr) { + throw RangeError('$idx is out of bounds'); + } else { + return result; + } +} + +/// Look up the single character abbreviation for a delta status code. +/// +/// When you run `git diff --name-status` it uses single letter codes in the output such as +/// 'A' for added, 'D' for deleted, 'M' for modified, etc. This function converts a [GitDelta] +/// value into these letters for your own purposes. [GitDelta.untracked] will return +/// a space (i.e. ' '). +String statusChar(int status) { + final result = libgit2.git_diff_status_char(status); + return String.fromCharCode(result); +} + +/// Accumulate diff statistics for all patches. +/// +/// Throws a [LibGit2Error] if error occured. +Pointer stats(Pointer diff) { + final out = calloc>(); + final error = libgit2.git_diff_get_stats(out, diff); + + if (error < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } else { + return out.value; + } +} + +/// Get the total number of insertions in a diff. +int statsInsertions(Pointer stats) => + libgit2.git_diff_stats_insertions(stats); + +/// Get the total number of deletions in a diff. +int statsDeletions(Pointer stats) => + libgit2.git_diff_stats_deletions(stats); + +/// Get the total number of files changed in a diff. +int statsFilesChanged(Pointer stats) => + libgit2.git_diff_stats_files_changed(stats); + +/// Print diff statistics. +/// +/// Throws a [LibGit2Error] if error occured. +String statsPrint( + Pointer stats, + int format, + int width, +) { + final out = calloc(sizeOf()); + final error = libgit2.git_diff_stats_to_buf(out, stats, format, width); + + if (error < 0) { + throw LibGit2Error(libgit2.git_error_last()); + } else { + final result = out.ref.ptr.cast().toDartString(); + calloc.free(out); + return result; + } +} + +/// Free a previously allocated diff stats. +void statsFree(Pointer stats) => + libgit2.git_diff_stats_free(stats); + +/// Free a previously allocated diff. +void free(Pointer diff) => libgit2.git_diff_free(diff); diff --git a/lib/src/diff.dart b/lib/src/diff.dart new file mode 100644 index 0000000..e363e63 --- /dev/null +++ b/lib/src/diff.dart @@ -0,0 +1,213 @@ +import 'dart:ffi'; +import 'package:ffi/ffi.dart'; +import 'bindings/libgit2_bindings.dart'; +import 'bindings/diff.dart' as bindings; +import 'git_types.dart'; +import 'oid.dart'; +import 'util.dart'; + +class Diff { + /// Initializes a new instance of [Diff] class from provided + /// pointer to diff object in memory. + /// + /// Should be freed with `free()` to release allocated memory. + Diff(this._diffPointer) { + libgit2.git_libgit2_init(); + } + + Diff.parse(String content) { + libgit2.git_libgit2_init(); + _diffPointer = bindings.parse(content); + } + + late final Pointer _diffPointer; + + /// Pointer to memory address for allocated diff object. + Pointer get pointer => _diffPointer; + + /// Queries how many diff records are there in a diff. + int get length => bindings.length(_diffPointer); + + /// Returns a list of [DiffDelta]s containing file pairs with and old and new revisions. + List get deltas { + final length = bindings.length(_diffPointer); + var deltas = []; + for (var i = 0; i < length; i++) { + deltas.add(DiffDelta(bindings.getDeltaByIndex(_diffPointer, i))); + } + return deltas; + } + + /// Accumulates diff statistics for all patches. + /// + /// Throws a [LibGit2Error] if error occured. + DiffStats get stats => DiffStats(bindings.stats(_diffPointer)); + + /// Merges one diff into another. + void merge(Diff diff) => bindings.merge(_diffPointer, diff.pointer); + + /// Transforms a diff marking file renames, copies, etc. + /// + /// This modifies a diff in place, replacing old entries that look like renames or copies + /// with new entries reflecting those changes. This also will, if requested, break modified + /// files into add/remove pairs if the amount of change is above a threshold. + /// + /// Throws a [LibGit2Error] if error occured. + void findSimilar({ + Set flags = const {GitDiffFind.byConfig}, + int renameThreshold = 50, + int copyThreshold = 50, + int renameFromRewriteThreshold = 50, + int breakRewriteThreshold = 60, + int renameLimit = 200, + }) { + final int flagsInt = + flags.fold(0, (previousValue, e) => previousValue | e.value); + + bindings.findSimilar( + _diffPointer, + flagsInt, + renameThreshold, + copyThreshold, + renameFromRewriteThreshold, + breakRewriteThreshold, + renameLimit, + ); + } + + /// Releases memory allocated for diff object. + void free() => bindings.free(_diffPointer); +} + +class DiffDelta { + /// Initializes a new instance of [DiffDelta] class from provided + /// pointer to diff delta object in memory. + DiffDelta(this._diffDeltaPointer); + + /// Pointer to memory address for allocated diff delta object. + final Pointer _diffDeltaPointer; + + /// Returns type of change. + GitDelta get status { + late final GitDelta status; + for (var type in GitDelta.values) { + if (_diffDeltaPointer.ref.status == type.value) { + status = type; + } + } + return status; + } + + /// Looks up the single character abbreviation for a delta status code. + /// + /// When you run `git diff --name-status` it uses single letter codes in the output such as + /// 'A' for added, 'D' for deleted, 'M' for modified, etc. This function converts a [GitDelta] + /// value into these letters for your own purposes. [GitDelta.untracked] will return + /// a space (i.e. ' '). + String get statusChar => bindings.statusChar(_diffDeltaPointer.ref.status); + + /// Returns flags for the delta object. + Set get flags { + var flags = {}; + for (var flag in GitDiffFlag.values) { + if (_diffDeltaPointer.ref.flags & flag.value == flag.value) { + flags.add(flag); + } + } + + return flags; + } + + /// Returns a similarity score for renamed or copied files between 0 and 100 + /// indicating how similar the old and new sides are. + /// + /// The similarity score is zero unless you call `find_similar()` which does + /// a similarity analysis of files in the diff. + int get similarity => _diffDeltaPointer.ref.similarity; + + /// Returns number of files in this delta. + int get numberOfFiles => _diffDeltaPointer.ref.nfiles; + + /// Represents the "from" side of the diff. + DiffFile get oldFile => DiffFile(_diffDeltaPointer.ref.old_file); + + /// Represents the "to" side of the diff. + DiffFile get newFile => DiffFile(_diffDeltaPointer.ref.new_file); +} + +/// Description of one side of a delta. +/// +/// Although this is called a "file", it could represent a file, a symbolic +/// link, a submodule commit id, or even a tree (although that only if you +/// are tracking type changes or ignored/untracked directories). +class DiffFile { + /// Initializes a new instance of [DiffFile] class from provided diff file object. + DiffFile(this._diffFile); + + final git_diff_file _diffFile; + + /// Returns oid of the item. If the entry represents an absent side of a diff + /// then the oid will be zeroes. + Oid get id => Oid.fromRaw(_diffFile.id); + + /// Returns path to the entry relative to the working directory of the repository. + String get path => _diffFile.path.cast().toDartString(); + + /// Returns the size of the entry in bytes. + int get size => _diffFile.size; + + /// Returns flags for the diff file object. + Set get flags { + var flags = {}; + for (var flag in GitDiffFlag.values) { + if (_diffFile.flags & flag.value == flag.value) { + flags.add(flag); + } + } + + return flags; + } + + /// Returns one of the [GitFilemode] values. + GitFilemode get mode { + late final GitFilemode result; + for (var mode in GitFilemode.values) { + if (_diffFile.mode == mode.value) { + result = mode; + } + } + return result; + } +} + +class DiffStats { + /// Initializes a new instance of [DiffStats] class from provided + /// pointer to diff stats object in memory. + DiffStats(this._diffStatsPointer); + + /// Pointer to memory address for allocated diff delta object. + final Pointer _diffStatsPointer; + + /// Returns the total number of insertions. + int get insertions => bindings.statsInsertions(_diffStatsPointer); + + /// Returns the total number of deletions. + int get deletions => bindings.statsDeletions(_diffStatsPointer); + + /// Returns the total number of files changed. + int get filesChanged => bindings.statsFilesChanged(_diffStatsPointer); + + /// Print diff statistics. + /// + /// Width for output only affects formatting of [GitDiffStats.full]. + /// + /// Throws a [LibGit2Error] if error occured. + String print(Set format, int width) { + final int formatInt = + format.fold(0, (previousValue, e) => previousValue | e.value); + return bindings.statsPrint(_diffStatsPointer, formatInt, width); + } + + /// Releases memory allocated for diff stats object. + void free() => bindings.statsFree(_diffStatsPointer); +} diff --git a/lib/src/git_types.dart b/lib/src/git_types.dart index d80604c..4700dbb 100644 --- a/lib/src/git_types.dart +++ b/lib/src/git_types.dart @@ -601,3 +601,405 @@ class GitReset { @override String toString() => 'GitReset.$_name'; } + +/// Flags for diff options. A combination of these flags can be passed. +class GitDiff { + const GitDiff._(this._value, this._name); + final int _value; + final String _name; + + /// Normal diff, the default. + static const normal = GitDiff._(0, 'normal'); + + /// Reverse the sides of the diff. + static const reverse = GitDiff._(1, 'reverse'); + + /// Include ignored files in the diff. + static const includeIgnored = GitDiff._(2, 'includeIgnored'); + + /// Even with [GitDiff.includeUntracked], an entire ignored directory + /// will be marked with only a single entry in the diff; this flag + /// adds all files under the directory as IGNORED entries, too. + static const recurseIgnoredDirs = GitDiff._(4, 'recurseIgnoredDirs'); + + /// Include untracked files in the diff. + static const includeUntracked = GitDiff._(8, 'includeUntracked'); + + /// Even with [GitDiff.includeUntracked], an entire untracked + /// directory will be marked with only a single entry in the diff + /// (a la what core Git does in `git status`); this flag adds *all* + /// files under untracked directories as UNTRACKED entries, too. + static const recurseUntrackedDirs = GitDiff._(16, 'recurseUntrackedDirs'); + + /// Include unmodified files in the diff. + static const includeUnmodified = GitDiff._(32, 'includeUnmodified'); + + /// Normally, a type change between files will be converted into a + /// DELETED record for the old and an ADDED record for the new; this + /// options enabled the generation of TYPECHANGE delta records. + static const includeTypechange = GitDiff._(64, 'includeTypechange'); + + /// Even with [GitDiff.includeTypechange], blob->tree changes still + /// generally show as a DELETED blob. This flag tries to correctly + /// label blob->tree transitions as TYPECHANGE records with new_file's + /// mode set to tree. Note: the tree SHA will not be available. + static const includeTypechangeTrees = + GitDiff._(128, 'includeTypechangeTrees'); + + /// Ignore file mode changes. + static const ignoreFilemode = GitDiff._(256, 'ignoreFilemode'); + + /// Treat all submodules as unmodified. + static const ignoreSubmodules = GitDiff._(512, 'ignoreSubmodules'); + + /// Use case insensitive filename comparisons. + static const ignoreCase = GitDiff._(1024, 'ignoreCase'); + + /// May be combined with [GitDiff.ignoreCase] to specify that a file + /// that has changed case will be returned as an add/delete pair. + static const includeCaseChange = GitDiff._(2048, 'includeCaseChange'); + + /// If the pathspec is set in the diff options, this flags indicates + /// that the paths will be treated as literal paths instead of + /// fnmatch patterns. Each path in the list must either be a full + /// path to a file or a directory. (A trailing slash indicates that + /// the path will _only_ match a directory). If a directory is + /// specified, all children will be included. + static const disablePathspecMatch = GitDiff._(4096, 'disablePathspecMatch'); + + /// Disable updating of the `binary` flag in delta records. This is + /// useful when iterating over a diff if you don't need hunk and data + /// callbacks and want to avoid having to load file completely. + static const skipBinaryCheck = GitDiff._(8192, 'skipBinaryCheck'); + + /// When diff finds an untracked directory, to match the behavior of + /// core Git, it scans the contents for IGNORED and UNTRACKED files. + /// If *all* contents are IGNORED, then the directory is IGNORED; if + /// any contents are not IGNORED, then the directory is UNTRACKED. + /// This is extra work that may not matter in many cases. This flag + /// turns off that scan and immediately labels an untracked directory + /// as UNTRACKED (changing the behavior to not match core Git). + static const enableFastUntrackedDirs = + GitDiff._(16384, 'enableFastUntrackedDirs'); + + /// When diff finds a file in the working directory with stat + /// information different from the index, but the OID ends up being the + /// same, write the correct stat information into the index. Note: + /// without this flag, diff will always leave the index untouched. + static const updateIndex = GitDiff._(32768, 'updateIndex'); + + /// Include unreadable files in the diff. + static const includeUnreadable = GitDiff._(65536, 'includeUnreadable'); + + /// Include unreadable files in the diff. + static const includeUnreadableAsUntracked = + GitDiff._(131072, 'includeUnreadableAsUntracked'); + + /// Use a heuristic that takes indentation and whitespace into account + /// which generally can produce better diffs when dealing with ambiguous + /// diff hunks. + static const indentHeuristic = GitDiff._(262144, 'indentHeuristic'); + + /// Treat all files as text, disabling binary attributes & detection. + static const forceText = GitDiff._(1048576, 'forceText'); + + /// Treat all files as binary, disabling text diffs. + static const forceBinary = GitDiff._(2097152, 'forceBinary'); + + /// Ignore all whitespace. + static const ignoreWhitespace = GitDiff._(4194304, 'ignoreWhitespace'); + + /// Ignore changes in amount of whitespace. + static const ignoreWhitespaceChange = + GitDiff._(8388608, 'ignoreWhitespaceChange'); + + /// Ignore whitespace at end of line. + static const ignoreWhitespaceEOL = GitDiff._(16777216, 'ignoreWhitespaceEOL'); + + /// When generating patch text, include the content of untracked + /// files. This automatically turns on [GitDiff.includeUntracked] but + /// it does not turn on [GitDiff.recurseUntrackedDirs]. Add that + /// flag if you want the content of every single UNTRACKED file. + static const showUntrackedContent = + GitDiff._(33554432, 'showUntrackedContent'); + + /// When generating output, include the names of unmodified files if + /// they are included in the git diff. Normally these are skipped in + /// the formats that list files (e.g. name-only, name-status, raw). + /// Even with this, these will not be included in patch format. + static const showUnmodified = GitDiff._(67108864, 'showUnmodified'); + + /// Use the "patience diff" algorithm. + static const patience = GitDiff._(268435456, 'patience'); + + /// Take extra time to find minimal diff. + static const minimal = GitDiff._(536870912, 'minimal'); + + /// Include the necessary deflate / delta information so that `git-apply` + /// can apply given diff information to binary files. + static const showBinary = GitDiff._(1073741824, 'showBinary'); + + static const List values = [ + normal, + reverse, + includeIgnored, + recurseIgnoredDirs, + includeUntracked, + recurseUntrackedDirs, + includeUnmodified, + includeTypechange, + includeTypechangeTrees, + ignoreFilemode, + ignoreSubmodules, + ignoreCase, + includeCaseChange, + disablePathspecMatch, + skipBinaryCheck, + enableFastUntrackedDirs, + updateIndex, + includeUnreadable, + includeUnreadableAsUntracked, + indentHeuristic, + forceText, + forceBinary, + ignoreWhitespace, + ignoreWhitespaceChange, + ignoreWhitespaceEOL, + showUntrackedContent, + showUnmodified, + patience, + minimal, + showBinary, + ]; + + int get value => _value; + + @override + String toString() => 'GitDiff.$_name'; +} + +/// What type of change is described by a git_diff_delta? +/// +/// [GitDelta.renamed] and [GitDelta.copied] will only show up if you run +/// `findSimilar()` on the diff object. +/// +/// [GitDelta.typechange] only shows up given [GitDiff.includeTypechange] +/// in the option flags (otherwise type changes will be split into ADDED / +/// DELETED pairs). +class GitDelta { + const GitDelta._(this._value, this._name); + final int _value; + final String _name; + + /// No changes. + static const unmodified = GitDelta._(0, 'unmodified'); + + /// Entry does not exist in old version. + static const added = GitDelta._(1, 'added'); + + /// Entry does not exist in new version. + static const deleted = GitDelta._(2, 'deleted'); + + /// Entry content changed between old and new. + static const modified = GitDelta._(3, 'modified'); + + /// Entry was renamed between old and new. + static const renamed = GitDelta._(4, 'renamed'); + + /// Entry was copied from another old entry. + static const copied = GitDelta._(5, 'copied'); + + /// Entry is ignored item in workdir. + static const ignored = GitDelta._(6, 'ignored'); + + /// Entry is is untracked item in workdir. + static const untracked = GitDelta._(7, 'untracked'); + + /// Type of entry changed between old and new. + static const typechange = GitDelta._(8, 'typechange'); + + /// Entry is unreadable. + static const unreadable = GitDelta._(9, 'unreadable'); + + /// Entry in the index is conflicted. + static const conflicted = GitDelta._(10, 'conflicted'); + + static const List values = [ + unmodified, + added, + deleted, + modified, + renamed, + copied, + ignored, + untracked, + typechange, + unreadable, + conflicted, + ]; + + int get value => _value; + + @override + String toString() => 'GitDelta.$_name'; +} + +/// Flags for the delta object and the file objects on each side. +class GitDiffFlag { + const GitDiffFlag._(this._value, this._name); + final int _value; + final String _name; + + /// File(s) treated as binary data. + static const binary = GitDiffFlag._(1, 'binary'); + + /// File(s) treated as text data. + static const notBinary = GitDiffFlag._(2, 'notBinary'); + + /// `id` value is known correct. + static const validId = GitDiffFlag._(4, 'validId'); + + /// File exists at this side of the delta. + static const exists = GitDiffFlag._(8, 'exists'); + + static const List values = [binary, notBinary, validId, exists]; + + int get value => _value; + + @override + String toString() => 'GitDiffFlag.$_name'; +} + +/// Formatting options for diff stats. +class GitDiffStats { + const GitDiffStats._(this._value, this._name); + final int _value; + final String _name; + + /// No stats. + static const none = GitDiffStats._(0, 'none'); + + /// Full statistics, equivalent of `--stat`. + static const full = GitDiffStats._(1, 'full'); + + /// Short statistics, equivalent of `--shortstat`. + static const short = GitDiffStats._(2, 'short'); + + /// Number statistics, equivalent of `--numstat`. + static const number = GitDiffStats._(4, 'number'); + + /// Extended header information such as creations, renames and mode changes, + /// equivalent of `--summary`. + static const includeSummary = GitDiffStats._(8, 'includeSummary'); + + static const List values = [ + none, + full, + short, + number, + includeSummary, + ]; + + int get value => _value; + + @override + String toString() => 'GitDiffStats.$_name'; +} + +/// Formatting options for diff stats. +class GitDiffFind { + const GitDiffFind._(this._value, this._name); + final int _value; + final String _name; + + /// Obey `diff.renames`. Overridden by any other [GitDiffFind] flag. + static const byConfig = GitDiffFind._(0, 'byConfig'); + + /// Look for renames? (`--find-renames`) + static const renames = GitDiffFind._(1, 'renames'); + + /// Consider old side of MODIFIED for renames? (`--break-rewrites=N`) + static const renamesFromRewrites = GitDiffFind._(2, 'renamesFromRewrites'); + + /// Look for copies? (a la `--find-copies`) + static const copies = GitDiffFind._(4, 'copies'); + + /// Consider UNMODIFIED as copy sources? (`--find-copies-harder`) + /// + /// For this to work correctly, use [GitDiff.includeUnmodified] when + /// the initial git diff is being generated. + static const copiesFromUnmodified = GitDiffFind._(8, 'copiesFromUnmodified'); + + /// Mark significant rewrites for split (`--break-rewrites=/M`) + static const rewrites = GitDiffFind._(16, 'rewrites'); + + /// Actually split large rewrites into delete/add pairs. + static const breakRewrites = GitDiffFind._(32, 'breakRewrites'); + + /// Mark rewrites for split and break into delete/add pairs. + static const andBreakRewrites = GitDiffFind._(48, 'andBreakRewrites'); + + /// Find renames/copies for UNTRACKED items in working directory. + /// + /// For this to work correctly, use [GitDiff.includeUntracked] when the + /// initial git diff is being generated (and obviously the diff must + /// be against the working directory for this to make sense). + static const forUntracked = GitDiffFind._(64, 'forUntracked'); + + /// Turn on all finding features. + static const all = GitDiffFind._(255, 'all'); + + /// Measure similarity ignoring all whitespace. + static const ignoreWhitespace = GitDiffFind._(4096, 'ignoreWhitespace'); + + /// Measure similarity including all data. + static const dontIgnoreWhitespace = + GitDiffFind._(8192, 'dontIgnoreWhitespace'); + + /// Measure similarity only by comparing SHAs (fast and cheap). + static const exactMatchOnly = GitDiffFind._(16384, 'exactMatchOnly'); + + /// Do not break rewrites unless they contribute to a rename. + /// + /// Normally, [GitDiffFind.andBreakRewrites] will measure the self- + /// similarity of modified files and split the ones that have changed a + /// lot into a DELETE / ADD pair. Then the sides of that pair will be + /// considered candidates for rename and copy detection. + /// + /// If you add this flag in and the split pair is *not* used for an + /// actual rename or copy, then the modified record will be restored to + /// a regular MODIFIED record instead of being split. + static const breakRewritesForRenamesOnly = + GitDiffFind._(32768, 'breakRewritesForRenamesOnly'); + + /// Remove any UNMODIFIED deltas after find_similar is done. + /// + /// Using [GitDiffFind.copiesFromUnmodified] to emulate the + /// --find-copies-harder behavior requires building a diff with the + /// [GitDiff.includeUnmodified] flag. If you do not want UNMODIFIED + /// records in the final result, pass this flag to have them removed. + static const removeUnmodified = GitDiffFind._(65536, 'removeUnmodified'); + + static const List values = [ + byConfig, + renames, + renamesFromRewrites, + copies, + copiesFromUnmodified, + rewrites, + breakRewrites, + andBreakRewrites, + forUntracked, + all, + ignoreWhitespace, + dontIgnoreWhitespace, + exactMatchOnly, + breakRewritesForRenamesOnly, + removeUnmodified, + ]; + + int get value => _value; + + @override + String toString() => 'GitDiffFind.$_name'; +} diff --git a/lib/src/index.dart b/lib/src/index.dart index b67d092..7bb826d 100644 --- a/lib/src/index.dart +++ b/lib/src/index.dart @@ -1,8 +1,10 @@ import 'dart:ffi'; import 'package:ffi/ffi.dart'; +import 'package:libgit2dart/libgit2dart.dart'; import 'package:libgit2dart/src/tree.dart'; import 'bindings/libgit2_bindings.dart'; import 'bindings/index.dart' as bindings; +import 'bindings/diff.dart' as diff_bindings; import 'oid.dart'; import 'git_types.dart'; import 'repository.dart'; @@ -17,9 +19,11 @@ class Index { libgit2.git_libgit2_init(); } - /// Pointer to memory address for allocated index object. late final Pointer _indexPointer; + /// Pointer to memory address for allocated index object. + Pointer get pointer => _indexPointer; + /// Returns index entry located at provided 0-based position or string path. /// /// Throws error if position is out of bounds or entry isn't found at path. @@ -154,7 +158,7 @@ class Index { /// Throws a [LibGit2Error] if error occured. void write() => bindings.write(_indexPointer); - /// Write the index as a tree. + /// Writes the index as a tree. /// /// This method will scan the index and write a representation of its current state back to disk; /// it recursively creates tree objects for each of the subtrees stored in the index, but only @@ -177,11 +181,55 @@ class Index { void remove(String path, [int stage = 0]) => bindings.remove(_indexPointer, path, stage); - /// Remove all matching index entries. + /// Removes all matching index entries. /// /// Throws a [LibGit2Error] if error occured. void removeAll(List path) => bindings.removeAll(_indexPointer, path); + /// Creates a diff between the repository index and the workdir directory. + /// + /// Throws a [LibGit2Error] if error occured. + Diff diffToWorkdir({ + Set flags = const {GitDiff.normal}, + int contextLines = 3, + int interhunkLines = 0, + }) { + final repo = bindings.owner(_indexPointer); + final int flagsInt = + flags.fold(0, (previousValue, e) => previousValue | e.value); + + return Diff(diff_bindings.indexToWorkdir( + repo, + _indexPointer, + flagsInt, + contextLines, + interhunkLines, + )); + } + + /// Creates a diff between a tree and repository index. + /// + /// Throws a [LibGit2Error] if error occured. + Diff diffToTree({ + required Tree tree, + Set flags = const {GitDiff.normal}, + int contextLines = 3, + int interhunkLines = 0, + }) { + final repo = bindings.owner(_indexPointer); + final int flagsInt = + flags.fold(0, (previousValue, e) => previousValue | e.value); + + return Diff(diff_bindings.treeToIndex( + repo, + tree.pointer, + _indexPointer, + flagsInt, + contextLines, + interhunkLines, + )); + } + /// Releases memory allocated for index object. void free() => bindings.free(_indexPointer); } diff --git a/lib/src/tree.dart b/lib/src/tree.dart index 3eb4fd1..c46997b 100644 --- a/lib/src/tree.dart +++ b/lib/src/tree.dart @@ -1,6 +1,9 @@ import 'dart:ffi'; import 'bindings/libgit2_bindings.dart'; import 'bindings/tree.dart' as bindings; +import 'bindings/diff.dart' as diff_bindings; +import 'diff.dart'; +import 'index.dart'; import 'repository.dart'; import 'oid.dart'; import 'git_types.dart'; @@ -67,6 +70,73 @@ class Tree { /// Get the number of entries listed in a tree. int get length => bindings.entryCount(_treePointer); + /// Creates a diff between a tree and the working directory. + /// + /// Throws a [LibGit2Error] if error occured. + Diff diffToWorkdir({ + Set flags = const {GitDiff.normal}, + int contextLines = 3, + int interhunkLines = 0, + }) { + final repo = bindings.owner(_treePointer); + final int flagsInt = + flags.fold(0, (previousValue, e) => previousValue | e.value); + + return Diff(diff_bindings.treeToWorkdir( + repo, + _treePointer, + flagsInt, + contextLines, + interhunkLines, + )); + } + + /// Creates a diff between a tree and repository index. + /// + /// Throws a [LibGit2Error] if error occured. + Diff diffToIndex({ + required Index index, + Set flags = const {GitDiff.normal}, + int contextLines = 3, + int interhunkLines = 0, + }) { + final repo = bindings.owner(_treePointer); + final int flagsInt = + flags.fold(0, (previousValue, e) => previousValue | e.value); + + return Diff(diff_bindings.treeToIndex( + repo, + _treePointer, + index.pointer, + flagsInt, + contextLines, + interhunkLines, + )); + } + + /// Creates a diff with the difference between two tree objects. + /// + /// Throws a [LibGit2Error] if error occured. + Diff diffToTree({ + required Tree tree, + Set flags = const {GitDiff.normal}, + int contextLines = 3, + int interhunkLines = 0, + }) { + final repo = bindings.owner(_treePointer); + final int flagsInt = + flags.fold(0, (previousValue, e) => previousValue | e.value); + + return Diff(diff_bindings.treeToTree( + repo, + _treePointer, + tree.pointer, + flagsInt, + contextLines, + interhunkLines, + )); + } + /// Releases memory allocated for tree object. void free() => bindings.free(_treePointer); } diff --git a/test/assets/dirtyrepo/.gitdir/COMMIT_EDITMSG b/test/assets/dirtyrepo/.gitdir/COMMIT_EDITMSG new file mode 100644 index 0000000..6841034 --- /dev/null +++ b/test/assets/dirtyrepo/.gitdir/COMMIT_EDITMSG @@ -0,0 +1 @@ +Initial commit of test files diff --git a/test/assets/dirtyrepo/.gitdir/HEAD b/test/assets/dirtyrepo/.gitdir/HEAD new file mode 100644 index 0000000..cb089cd --- /dev/null +++ b/test/assets/dirtyrepo/.gitdir/HEAD @@ -0,0 +1 @@ +ref: refs/heads/master diff --git a/test/assets/dirtyrepo/.gitdir/branches/empty_marker b/test/assets/dirtyrepo/.gitdir/branches/empty_marker new file mode 100644 index 0000000..e69de29 diff --git a/test/assets/dirtyrepo/.gitdir/config b/test/assets/dirtyrepo/.gitdir/config new file mode 100644 index 0000000..515f483 --- /dev/null +++ b/test/assets/dirtyrepo/.gitdir/config @@ -0,0 +1,5 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = false + logallrefupdates = true diff --git a/test/assets/dirtyrepo/.gitdir/description b/test/assets/dirtyrepo/.gitdir/description new file mode 100644 index 0000000..498b267 --- /dev/null +++ b/test/assets/dirtyrepo/.gitdir/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/test/assets/dirtyrepo/.gitdir/hooks/applypatch-msg.sample b/test/assets/dirtyrepo/.gitdir/hooks/applypatch-msg.sample new file mode 100755 index 0000000..8b2a2fe --- /dev/null +++ b/test/assets/dirtyrepo/.gitdir/hooks/applypatch-msg.sample @@ -0,0 +1,15 @@ +#!/bin/sh +# +# An example hook script to check the commit log message taken by +# applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. The hook is +# allowed to edit the commit message file. +# +# To enable this hook, rename this file to "applypatch-msg". + +. git-sh-setup +test -x "$GIT_DIR/hooks/commit-msg" && + exec "$GIT_DIR/hooks/commit-msg" ${1+"$@"} +: diff --git a/test/assets/dirtyrepo/.gitdir/hooks/commit-msg.sample b/test/assets/dirtyrepo/.gitdir/hooks/commit-msg.sample new file mode 100755 index 0000000..b58d118 --- /dev/null +++ b/test/assets/dirtyrepo/.gitdir/hooks/commit-msg.sample @@ -0,0 +1,24 @@ +#!/bin/sh +# +# An example hook script to check the commit log message. +# Called by "git commit" with one argument, the name of the file +# that has the commit message. The hook should exit with non-zero +# status after issuing an appropriate message if it wants to stop the +# commit. The hook is allowed to edit the commit message file. +# +# To enable this hook, rename this file to "commit-msg". + +# Uncomment the below to add a Signed-off-by line to the message. +# Doing this in a hook is a bad idea in general, but the prepare-commit-msg +# hook is more suited to it. +# +# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" + +# This example catches duplicate Signed-off-by lines. + +test "" = "$(grep '^Signed-off-by: ' "$1" | + sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || { + echo >&2 Duplicate Signed-off-by lines. + exit 1 +} diff --git a/test/assets/dirtyrepo/.gitdir/hooks/post-commit.sample b/test/assets/dirtyrepo/.gitdir/hooks/post-commit.sample new file mode 100755 index 0000000..2266821 --- /dev/null +++ b/test/assets/dirtyrepo/.gitdir/hooks/post-commit.sample @@ -0,0 +1,8 @@ +#!/bin/sh +# +# An example hook script that is called after a successful +# commit is made. +# +# To enable this hook, rename this file to "post-commit". + +: Nothing diff --git a/test/assets/dirtyrepo/.gitdir/hooks/post-receive.sample b/test/assets/dirtyrepo/.gitdir/hooks/post-receive.sample new file mode 100755 index 0000000..7a83e17 --- /dev/null +++ b/test/assets/dirtyrepo/.gitdir/hooks/post-receive.sample @@ -0,0 +1,15 @@ +#!/bin/sh +# +# An example hook script for the "post-receive" event. +# +# The "post-receive" script is run after receive-pack has accepted a pack +# and the repository has been updated. It is passed arguments in through +# stdin in the form +# +# For example: +# aa453216d1b3e49e7f6f98441fa56946ddcd6a20 68f7abf4e6f922807889f52bc043ecd31b79f814 refs/heads/master +# +# see contrib/hooks/ for a sample, or uncomment the next line and +# rename the file to "post-receive". + +#. /usr/share/doc/git-core/contrib/hooks/post-receive-email diff --git a/test/assets/dirtyrepo/.gitdir/hooks/post-update.sample b/test/assets/dirtyrepo/.gitdir/hooks/post-update.sample new file mode 100755 index 0000000..ec17ec1 --- /dev/null +++ b/test/assets/dirtyrepo/.gitdir/hooks/post-update.sample @@ -0,0 +1,8 @@ +#!/bin/sh +# +# An example hook script to prepare a packed repository for use over +# dumb transports. +# +# To enable this hook, rename this file to "post-update". + +exec git update-server-info diff --git a/test/assets/dirtyrepo/.gitdir/hooks/pre-applypatch.sample b/test/assets/dirtyrepo/.gitdir/hooks/pre-applypatch.sample new file mode 100755 index 0000000..b1f187c --- /dev/null +++ b/test/assets/dirtyrepo/.gitdir/hooks/pre-applypatch.sample @@ -0,0 +1,14 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed +# by applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-applypatch". + +. git-sh-setup +test -x "$GIT_DIR/hooks/pre-commit" && + exec "$GIT_DIR/hooks/pre-commit" ${1+"$@"} +: diff --git a/test/assets/dirtyrepo/.gitdir/hooks/pre-commit.sample b/test/assets/dirtyrepo/.gitdir/hooks/pre-commit.sample new file mode 100755 index 0000000..b187c4b --- /dev/null +++ b/test/assets/dirtyrepo/.gitdir/hooks/pre-commit.sample @@ -0,0 +1,46 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed. +# Called by "git commit" with no arguments. The hook should +# exit with non-zero status after issuing an appropriate message if +# it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-commit". + +if git rev-parse --verify HEAD >/dev/null 2>&1 +then + against=HEAD +else + # Initial commit: diff against an empty tree object + against=4b825dc642cb6eb9a060e54bf8d69288fbee4904 +fi + +# If you want to allow non-ascii filenames set this variable to true. +allownonascii=$(git config hooks.allownonascii) + +# Cross platform projects tend to avoid non-ascii filenames; prevent +# them from being added to the repository. We exploit the fact that the +# printable range starts at the space character and ends with tilde. +if [ "$allownonascii" != "true" ] && + # Note that the use of brackets around a tr range is ok here, (it's + # even required, for portability to Solaris 10's /usr/bin/tr), since + # the square bracket bytes happen to fall in the designated range. + test "$(git diff --cached --name-only --diff-filter=A -z $against | + LC_ALL=C tr -d '[ -~]\0')" +then + echo "Error: Attempt to add a non-ascii file name." + echo + echo "This can cause problems if you want to work" + echo "with people on other platforms." + echo + echo "To be portable it is advisable to rename the file ..." + echo + echo "If you know what you are doing you can disable this" + echo "check using:" + echo + echo " git config hooks.allownonascii true" + echo + exit 1 +fi + +exec git diff-index --check --cached $against -- diff --git a/test/assets/dirtyrepo/.gitdir/hooks/pre-rebase.sample b/test/assets/dirtyrepo/.gitdir/hooks/pre-rebase.sample new file mode 100755 index 0000000..f0f6da3 --- /dev/null +++ b/test/assets/dirtyrepo/.gitdir/hooks/pre-rebase.sample @@ -0,0 +1,172 @@ +#!/bin/sh +# +# Copyright (c) 2006, 2008 Junio C Hamano +# +# The "pre-rebase" hook is run just before "git rebase" starts doing +# its job, and can prevent the command from running by exiting with +# non-zero status. +# +# The hook is called with the following parameters: +# +# $1 -- the upstream the series was forked from. +# $2 -- the branch being rebased (or empty when rebasing the current branch). +# +# This sample shows how to prevent topic branches that are already +# merged to 'next' branch from getting rebased, because allowing it +# would result in rebasing already published history. + +publish=next +basebranch="$1" +if test "$#" = 2 +then + topic="refs/heads/$2" +else + topic=`git symbolic-ref HEAD` || + exit 0 ;# we do not interrupt rebasing detached HEAD +fi + +case "$topic" in +refs/heads/??/*) + ;; +*) + exit 0 ;# we do not interrupt others. + ;; +esac + +# Now we are dealing with a topic branch being rebased +# on top of master. Is it OK to rebase it? + +# Does the topic really exist? +git show-ref -q "$topic" || { + echo >&2 "No such branch $topic" + exit 1 +} + +# Is topic fully merged to master? +not_in_master=`git rev-list --pretty=oneline ^master "$topic"` +if test -z "$not_in_master" +then + echo >&2 "$topic is fully merged to master; better remove it." + exit 1 ;# we could allow it, but there is no point. +fi + +# Is topic ever merged to next? If so you should not be rebasing it. +only_next_1=`git rev-list ^master "^$topic" ${publish} | sort` +only_next_2=`git rev-list ^master ${publish} | sort` +if test "$only_next_1" = "$only_next_2" +then + not_in_topic=`git rev-list "^$topic" master` + if test -z "$not_in_topic" + then + echo >&2 "$topic is already up-to-date with master" + exit 1 ;# we could allow it, but there is no point. + else + exit 0 + fi +else + not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"` + /usr/bin/perl -e ' + my $topic = $ARGV[0]; + my $msg = "* $topic has commits already merged to public branch:\n"; + my (%not_in_next) = map { + /^([0-9a-f]+) /; + ($1 => 1); + } split(/\n/, $ARGV[1]); + for my $elem (map { + /^([0-9a-f]+) (.*)$/; + [$1 => $2]; + } split(/\n/, $ARGV[2])) { + if (!exists $not_in_next{$elem->[0]}) { + if ($msg) { + print STDERR $msg; + undef $msg; + } + print STDERR " $elem->[1]\n"; + } + } + ' "$topic" "$not_in_next" "$not_in_master" + exit 1 +fi + +exit 0 + +<<\DOC_END +################################################################ + +This sample hook safeguards topic branches that have been +published from being rewound. + +The workflow assumed here is: + + * Once a topic branch forks from "master", "master" is never + merged into it again (either directly or indirectly). + + * Once a topic branch is fully cooked and merged into "master", + it is deleted. If you need to build on top of it to correct + earlier mistakes, a new topic branch is created by forking at + the tip of the "master". This is not strictly necessary, but + it makes it easier to keep your history simple. + + * Whenever you need to test or publish your changes to topic + branches, merge them into "next" branch. + +The script, being an example, hardcodes the publish branch name +to be "next", but it is trivial to make it configurable via +$GIT_DIR/config mechanism. + +With this workflow, you would want to know: + +(1) ... if a topic branch has ever been merged to "next". Young + topic branches can have stupid mistakes you would rather + clean up before publishing, and things that have not been + merged into other branches can be easily rebased without + affecting other people. But once it is published, you would + not want to rewind it. + +(2) ... if a topic branch has been fully merged to "master". + Then you can delete it. More importantly, you should not + build on top of it -- other people may already want to + change things related to the topic as patches against your + "master", so if you need further changes, it is better to + fork the topic (perhaps with the same name) afresh from the + tip of "master". + +Let's look at this example: + + o---o---o---o---o---o---o---o---o---o "next" + / / / / + / a---a---b A / / + / / / / + / / c---c---c---c B / + / / / \ / + / / / b---b C \ / + / / / / \ / + ---o---o---o---o---o---o---o---o---o---o---o "master" + + +A, B and C are topic branches. + + * A has one fix since it was merged up to "next". + + * B has finished. It has been fully merged up to "master" and "next", + and is ready to be deleted. + + * C has not merged to "next" at all. + +We would want to allow C to be rebased, refuse A, and encourage +B to be deleted. + +To compute (1): + + git rev-list ^master ^topic next + git rev-list ^master next + + if these match, topic has not merged in next at all. + +To compute (2): + + git rev-list master..topic + + if this is empty, it is fully merged to "master". + +DOC_END diff --git a/test/assets/dirtyrepo/.gitdir/hooks/prepare-commit-msg.sample b/test/assets/dirtyrepo/.gitdir/hooks/prepare-commit-msg.sample new file mode 100755 index 0000000..f093a02 --- /dev/null +++ b/test/assets/dirtyrepo/.gitdir/hooks/prepare-commit-msg.sample @@ -0,0 +1,36 @@ +#!/bin/sh +# +# An example hook script to prepare the commit log message. +# Called by "git commit" with the name of the file that has the +# commit message, followed by the description of the commit +# message's source. The hook's purpose is to edit the commit +# message file. If the hook fails with a non-zero status, +# the commit is aborted. +# +# To enable this hook, rename this file to "prepare-commit-msg". + +# This hook includes three examples. The first comments out the +# "Conflicts:" part of a merge commit. +# +# The second includes the output of "git diff --name-status -r" +# into the message, just before the "git status" output. It is +# commented because it doesn't cope with --amend or with squashed +# commits. +# +# The third example adds a Signed-off-by line to the message, that can +# still be edited. This is rarely a good idea. + +case "$2,$3" in + merge,) + /usr/bin/perl -i.bak -ne 's/^/# /, s/^# #/#/ if /^Conflicts/ .. /#/; print' "$1" ;; + +# ,|template,) +# /usr/bin/perl -i.bak -pe ' +# print "\n" . `git diff --cached --name-status -r` +# if /^#/ && $first++ == 0' "$1" ;; + + *) ;; +esac + +# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" diff --git a/test/assets/dirtyrepo/.gitdir/hooks/update.sample b/test/assets/dirtyrepo/.gitdir/hooks/update.sample new file mode 100755 index 0000000..71ab04e --- /dev/null +++ b/test/assets/dirtyrepo/.gitdir/hooks/update.sample @@ -0,0 +1,128 @@ +#!/bin/sh +# +# An example hook script to blocks unannotated tags from entering. +# Called by "git receive-pack" with arguments: refname sha1-old sha1-new +# +# To enable this hook, rename this file to "update". +# +# Config +# ------ +# hooks.allowunannotated +# This boolean sets whether unannotated tags will be allowed into the +# repository. By default they won't be. +# hooks.allowdeletetag +# This boolean sets whether deleting tags will be allowed in the +# repository. By default they won't be. +# hooks.allowmodifytag +# This boolean sets whether a tag may be modified after creation. By default +# it won't be. +# hooks.allowdeletebranch +# This boolean sets whether deleting branches will be allowed in the +# repository. By default they won't be. +# hooks.denycreatebranch +# This boolean sets whether remotely creating branches will be denied +# in the repository. By default this is allowed. +# + +# --- Command line +refname="$1" +oldrev="$2" +newrev="$3" + +# --- Safety check +if [ -z "$GIT_DIR" ]; then + echo "Don't run this script from the command line." >&2 + echo " (if you want, you could supply GIT_DIR then run" >&2 + echo " $0 )" >&2 + exit 1 +fi + +if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +# --- Config +allowunannotated=$(git config --bool hooks.allowunannotated) +allowdeletebranch=$(git config --bool hooks.allowdeletebranch) +denycreatebranch=$(git config --bool hooks.denycreatebranch) +allowdeletetag=$(git config --bool hooks.allowdeletetag) +allowmodifytag=$(git config --bool hooks.allowmodifytag) + +# check for no description +projectdesc=$(sed -e '1q' "$GIT_DIR/description") +case "$projectdesc" in +"Unnamed repository"* | "") + echo "*** Project description file hasn't been set" >&2 + exit 1 + ;; +esac + +# --- Check types +# if $newrev is 0000...0000, it's a commit to delete a ref. +zero="0000000000000000000000000000000000000000" +if [ "$newrev" = "$zero" ]; then + newrev_type=delete +else + newrev_type=$(git cat-file -t $newrev) +fi + +case "$refname","$newrev_type" in + refs/tags/*,commit) + # un-annotated tag + short_refname=${refname##refs/tags/} + if [ "$allowunannotated" != "true" ]; then + echo "*** The un-annotated tag, $short_refname, is not allowed in this repository" >&2 + echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2 + exit 1 + fi + ;; + refs/tags/*,delete) + # delete tag + if [ "$allowdeletetag" != "true" ]; then + echo "*** Deleting a tag is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/tags/*,tag) + # annotated tag + if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1 + then + echo "*** Tag '$refname' already exists." >&2 + echo "*** Modifying a tag is not allowed in this repository." >&2 + exit 1 + fi + ;; + refs/heads/*,commit) + # branch + if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then + echo "*** Creating a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/heads/*,delete) + # delete branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/remotes/*,commit) + # tracking branch + ;; + refs/remotes/*,delete) + # delete tracking branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a tracking branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + *) + # Anything else (is there anything else?) + echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2 + exit 1 + ;; +esac + +# --- Finished +exit 0 diff --git a/test/assets/dirtyrepo/.gitdir/index b/test/assets/dirtyrepo/.gitdir/index new file mode 100644 index 0000000000000000000000000000000000000000..4dcf9930ab80135ae90d53147915a23d836e583e GIT binary patch literal 1064 zcmZ?q402{*U|<4boc*ibJ;4(hc#=ztic<4R;?pv7QX!iC)ZdqbSTLG_frE{KQEn=6=7G$K zPf5*5ElEwmYhDyl=JDp{r(~vOrlueq2=?!F?_fXmk8%(jK>n3tL^DtDkoYl^%r}dJ zdsa+a8ZS9PNNU3Tc?^8TC5h=k(~>h1^U_m`A^!ZRhD<~KJB=vwq*2WSyAsv=z;OJi z4K*K3L(PvR%6u7Y=EK4S;(Ks-JnNn9r~c9pA_5K%VA>!e{<)y$=cR(<9OBNGz_aQd~<^usJJ+KgE zu5fW_Qc7l#K4vxm`u~+I$S@cUbuTcN5aC{!xzL<|NWEb56rtvVX{fnrM42lBbuY~O cNCrnWY>VG8?~l%dw@vG=YrETA^q#U00HeK2l>h($ literal 0 HcmV?d00001 diff --git a/test/assets/dirtyrepo/.gitdir/info/exclude b/test/assets/dirtyrepo/.gitdir/info/exclude new file mode 100644 index 0000000..a5196d1 --- /dev/null +++ b/test/assets/dirtyrepo/.gitdir/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/test/assets/dirtyrepo/.gitdir/logs/HEAD b/test/assets/dirtyrepo/.gitdir/logs/HEAD new file mode 100644 index 0000000..ec94661 --- /dev/null +++ b/test/assets/dirtyrepo/.gitdir/logs/HEAD @@ -0,0 +1 @@ +0000000000000000000000000000000000000000 a763aa560953e7cfb87ccbc2f536d665aa4dff22 Julien Miotte 1311240164 +0200 commit (initial): Initial commit of test files diff --git a/test/assets/dirtyrepo/.gitdir/logs/refs/heads/master b/test/assets/dirtyrepo/.gitdir/logs/refs/heads/master new file mode 100644 index 0000000..ec94661 --- /dev/null +++ b/test/assets/dirtyrepo/.gitdir/logs/refs/heads/master @@ -0,0 +1 @@ +0000000000000000000000000000000000000000 a763aa560953e7cfb87ccbc2f536d665aa4dff22 Julien Miotte 1311240164 +0200 commit (initial): Initial commit of test files diff --git a/test/assets/dirtyrepo/.gitdir/objects/a7/63aa560953e7cfb87ccbc2f536d665aa4dff22 b/test/assets/dirtyrepo/.gitdir/objects/a7/63aa560953e7cfb87ccbc2f536d665aa4dff22 new file mode 100644 index 0000000..5950493 --- /dev/null +++ b/test/assets/dirtyrepo/.gitdir/objects/a7/63aa560953e7cfb87ccbc2f536d665aa4dff22 @@ -0,0 +1,2 @@ +xK +0a9셒GU1`H;o_| ":Ĕ03ѡ F&=_GlqW_UԙFo>dxyHuHA((bA\gb~+CrC \ No newline at end of file diff --git a/test/assets/dirtyrepo/.gitdir/objects/b8/5d53c9236e89aff2b62558adaa885fd1d6ff1c b/test/assets/dirtyrepo/.gitdir/objects/b8/5d53c9236e89aff2b62558adaa885fd1d6ff1c new file mode 100644 index 0000000000000000000000000000000000000000..d31fefdfcb7e3ec81fb6a171bd8283be9c21b10d GIT binary patch literal 78 zcmV-U0I~mg0V^p=O;s>7GGs6`FfcPQQAjQ=DoV{OiBHSSNo9C8_tET47q2;ccWbUI kkGgT_Nl)-ZsJfKYoYa!k6oTq<^HVa@GVv({0IfVZf|BMTd;kCd literal 0 HcmV?d00001 diff --git a/test/assets/dirtyrepo/.gitdir/objects/c2/17c63469eca3538ca896a55f1990121a909f9e b/test/assets/dirtyrepo/.gitdir/objects/c2/17c63469eca3538ca896a55f1990121a909f9e new file mode 100644 index 0000000000000000000000000000000000000000..95d8a647f8894545d4460489692ff6916404f3b7 GIT binary patch literal 33 pcmbkso!9m}tNoCPA!`vw9sv8G4p0C9 literal 0 HcmV?d00001 diff --git a/test/assets/dirtyrepo/.gitdir/objects/e3/728acced34e063a6e5830c52659f7fdb7a7858 b/test/assets/dirtyrepo/.gitdir/objects/e3/728acced34e063a6e5830c52659f7fdb7a7858 new file mode 100644 index 0000000000000000000000000000000000000000..1aa4181f0b58d1898fa29e721b9640e49c6002eb GIT binary patch literal 154 zcmV;L0A>Gp0V^p=O;s>4FlI0`FfcPQQAjQ=DoV{OiBHSSNo9C8_tET47q2;ccWbUI zkGgT_Nl)-Zs5+2*d`fCgYDsDeK6SbIDVb@RsVM{vEG|h*2P#X>NX$!5Eyiags!FiI z#JB{THkj*(unyu^q7_1%h1Yx&10YZ+E=@|wEMnLZ8+=kZuXFvUZK@G#S9Qc+y!KxP I0KcZU3p8&(EC2ui literal 0 HcmV?d00001 diff --git a/test/assets/dirtyrepo/.gitdir/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 b/test/assets/dirtyrepo/.gitdir/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 new file mode 100644 index 0000000000000000000000000000000000000000..711223894375fe1186ac5bfffdc48fb1fa1e65cc GIT binary patch literal 15 Wcmb e.newFile.path == 'staged_new').status, + GitDelta.added, + ); + + diff.findSimilar(); + expect( + diff.deltas.singleWhere((e) => e.newFile.path == 'staged_new').status, + GitDelta.renamed, + ); + + diff.free(); + index.free(); + oldTree.free(); + newTree.free(); + }); + + test('returns deltas and patches', () { + final index = repo.index; + final diff = index.diffToWorkdir(); + + expect(diff.deltas[0].numberOfFiles, 1); + expect(diff.deltas[0].status, GitDelta.deleted); + expect(diff.deltas[0].statusChar, 'D'); + expect(diff.deltas[0].flags, isEmpty); + expect(diff.deltas[0].similarity, 0); + + expect(diff.deltas[0].oldFile.path, indexToWorkdir[0]); + expect( + diff.deltas[0].oldFile.id.sha, + 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391', + ); + expect( + diff.deltas[0].newFile.id.sha, + '0000000000000000000000000000000000000000', + ); + + expect(diff.deltas[2].oldFile.size, 17); + + expect( + diff.deltas[0].oldFile.flags, + {GitDiffFlag.validId, GitDiffFlag.exists}, + ); + expect( + diff.deltas[0].newFile.flags, + {GitDiffFlag.validId}, + ); + + expect(diff.deltas[0].oldFile.mode, GitFilemode.blob); + + diff.free(); + index.free(); + }); + + test('returns stats', () { + final index = repo.index; + final diff = index.diffToWorkdir(); + final stats = diff.stats; + + expect(stats.insertions, 4); + expect(stats.deletions, 2); + expect(stats.filesChanged, 8); + expect(stats.print({GitDiffStats.full}, 80), statsPrint); + + stats.free(); + diff.free(); + index.free(); + }); + }); +} diff --git a/test/reset_test.dart b/test/reset_test.dart index 78422b2..e2d700b 100644 --- a/test/reset_test.dart +++ b/test/reset_test.dart @@ -43,15 +43,27 @@ void main() { repo.reset(sha, GitReset.soft); contents = file.readAsStringSync(); expect(contents, 'Feature edit\n'); + + final index = repo.index; + final diff = index.diffToWorkdir(); + expect(diff.deltas, isEmpty); + + index.free(); }); - test('successfully resets with soft', () { + test('successfully resets with mixed', () { var contents = file.readAsStringSync(); expect(contents, 'Feature edit\n'); repo.reset(sha, GitReset.mixed); contents = file.readAsStringSync(); expect(contents, 'Feature edit\n'); + + final index = repo.index; + final diff = index.diffToWorkdir(); + expect(diff.deltas.length, 1); + + index.free(); }); }); }