Repository: libgit2/git2go
Pull request #629 (2020-08-14 10:24:50)

More diff functionality
This PR adds
- The ability to apply a Diff object to the repo
- Support for git_apply_hunk_cb and git_apply_delta_cb callbacks in options for applying the diffs
- The ability to import a diff from a raw buffer (for example, one exported by ToBuf) into a Diff object associated with the repo
- Tests for the above

The test files might be a bit messy but there's quite a lot of stuff to cover.


Patch:
diff --git a/diff.go b/diff.go
index e022b47..9d17dd7 100644
--- a/diff.go
+++ b/diff.go
@@ -3,6 +3,7 @@ package git
 /*
 #include <git2.h>
 
+extern void _go_git_populate_apply_cb(git_apply_options *options);
 extern int _go_git_diff_foreach(git_diff *diff, int eachFile, int eachHunk, int eachLine, void *payload);
 extern void _go_git_setup_diff_notify_callbacks(git_diff_options* opts);
 extern int _go_git_diff_blobs(git_blob *old, const char *old_path, git_blob *new, const char *new_path, git_diff_options *opts, int eachFile, int eachHunk, int eachLine, void *payload);
@@ -550,7 +551,7 @@ const (
 	DiffFindRemoveUnmodified            DiffFindOptionsFlag = C.GIT_DIFF_FIND_REMOVE_UNMODIFIED
 )
 
-//TODO implement git_diff_similarity_metric
+// TODO implement git_diff_similarity_metric
 type DiffFindOptions struct {
 	Flags                      DiffFindOptionsFlag
 	RenameThreshold            uint16
@@ -847,3 +848,174 @@ func DiffBlobs(oldBlob *Blob, oldAsPath string, newBlob *Blob, newAsPath string,
 
 	return nil
 }
+
+// ApplyHunkCallback is a callback that will be made per delta (file) when applying a patch.
+type ApplyHunkCallback func(*DiffHunk) (apply bool, err error)
+
+// ApplyDeltaCallback is a callback that will be made per hunk when applying a patch.
+type ApplyDeltaCallback func(*DiffDelta) (apply bool, err error)
+
+// ApplyOptions has 2 callbacks that are called for hunks or deltas
+// If these functions return an error, abort the apply process immediately.
+// If the first return value is true, the delta/hunk will be applied. If it is false, the  delta/hunk will not be applied. In either case, the rest of the apply process will continue.
+type ApplyOptions struct {
+	ApplyHunkCallback  ApplyHunkCallback
+	ApplyDeltaCallback ApplyDeltaCallback
+	Flags              uint
+}
+
+//export hunkApplyCallback
+func hunkApplyCallback(_hunk *C.git_diff_hunk, _payload unsafe.Pointer) C.int {
+	opts, ok := pointerHandles.Get(_payload).(*ApplyOptions)
+	if !ok {
+		panic("invalid apply options payload")
+	}
+
+	if opts.ApplyHunkCallback == nil {
+		return 0
+	}
+
+	hunk := diffHunkFromC(_hunk)
+
+	apply, err := opts.ApplyHunkCallback(&hunk)
+	if err != nil {
+		if gitError, ok := err.(*GitError); ok {
+			return C.int(gitError.Code)
+		}
+		return -1
+	} else if apply {
+		return 0
+	} else {
+		return 1
+	}
+}
+
+//export deltaApplyCallback
+func deltaApplyCallback(_delta *C.git_diff_delta, _payload unsafe.Pointer) C.int {
+	opts, ok := pointerHandles.Get(_payload).(*ApplyOptions)
+	if !ok {
+		panic("invalid apply options payload")
+	}
+
+	if opts.ApplyDeltaCallback == nil {
+		return 0
+	}
+
+	delta := diffDeltaFromC(_delta)
+
+	apply, err := opts.ApplyDeltaCallback(&delta)
+	if err != nil {
+		if gitError, ok := err.(*GitError); ok {
+			return C.int(gitError.Code)
+		}
+		return -1
+	} else if apply {
+		return 0
+	} else {
+		return 1
+	}
+}
+
+// DefaultApplyOptions returns default options for applying diffs or patches.
+func DefaultApplyOptions() (*ApplyOptions, error) {
+	opts := C.git_apply_options{}
+
+	runtime.LockOSThread()
+	defer runtime.UnlockOSThread()
+
+	ecode := C.git_apply_options_init(&opts, C.GIT_APPLY_OPTIONS_VERSION)
+	if int(ecode) != 0 {
+
+		return nil, MakeGitError(ecode)
+	}
+
+	return applyOptionsFromC(&opts), nil
+}
+
+func (a *ApplyOptions) toC() *C.git_apply_options {
+	if a == nil {
+		return nil
+	}
+
+	opts := &C.git_apply_options{
+		version: C.GIT_APPLY_OPTIONS_VERSION,
+		flags:   C.uint(a.Flags),
+	}
+
+	if a.ApplyDeltaCallback != nil || a.ApplyHunkCallback != nil {
+		C._go_git_populate_apply_cb(opts)
+		opts.payload = pointerHandles.Track(a)
+	}
+
+	return opts
+}
+
+func applyOptionsFromC(opts *C.git_apply_options) *ApplyOptions {
+	return &ApplyOptions{
+		Flags: uint(opts.flags),
+	}
+}
+
+// ApplyLocation represents the possible application locations for applying
+// diffs.
+type ApplyLocation int
+
+const (
+	// ApplyLocationWorkdir applies the patch to the workdir, leaving the
+	// index untouched. This is the equivalent of `git apply` with no location
+	// argument.
+	ApplyLocationWorkdir ApplyLocation = C.GIT_APPLY_LOCATION_WORKDIR
+	// ApplyLocationIndex applies the patch to the index, leaving the working
+	// directory untouched. This is the equivalent of `git apply --cached`.
+	ApplyLocationIndex ApplyLocation = C.GIT_APPLY_LOCATION_INDEX
+	// ApplyLocationBoth applies the patch to both the working directory and
+	// the index. This is the equivalent of `git apply --index`.
+	ApplyLocationBoth ApplyLocation = C.GIT_APPLY_LOCATION_BOTH
+)
+
+// ApplyDiff appllies a Diff to the given repository, making changes directly
+// in the working directory, the index, or both.
+func (v *Repository) ApplyDiff(diff *Diff, location ApplyLocation, opts *ApplyOptions) error {
+	runtime.LockOSThread()
+	defer runtime.UnlockOSThread()
+
+	cOpts := opts.toC()
+	ecode := C.git_apply(v.ptr, diff.ptr, C.git_apply_location_t(location), cOpts)
+	runtime.KeepAlive(v)
+	runtime.KeepAlive(diff)
+	runtime.KeepAlive(cOpts)
+	if ecode < 0 {
+		return MakeGitError(ecode)
+	}
+
+	return nil
+}
+
+// DiffFromBuffer reads the contents of a git patch file into a 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 git_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.
+func DiffFromBuffer(buffer []byte, repo *Repository) (*Diff, error) {
+	var diff *C.git_diff
+
+	cBuffer := C.CBytes(buffer)
+	defer C.free(unsafe.Pointer(cBuffer))
+
+	runtime.LockOSThread()
+	defer runtime.UnlockOSThread()
+
+	ecode := C.git_diff_from_buffer(&diff, (*C.char)(cBuffer), C.size_t(len(buffer)))
+	if ecode < 0 {
+		return nil, MakeGitError(ecode)
+	}
+	runtime.KeepAlive(diff)
+
+	return newDiffFromC(diff, repo), nil
+}
diff --git a/wrapper.c b/wrapper.c
index c4a5ff0..4308ae4 100644
--- a/wrapper.c
+++ b/wrapper.c
@@ -6,6 +6,12 @@
 
 typedef int (*gogit_submodule_cbk)(git_submodule *sm, const char *name, void *payload);
 
+void _go_git_populate_apply_cb(git_apply_options *options)
+{
+	options->delta_cb = (git_apply_delta_cb)deltaApplyCallback;
+	options->hunk_cb = (git_apply_hunk_cb)hunkApplyCallback;
+}
+
 void _go_git_populate_remote_cb(git_clone_options *opts)
 {
 	opts->remote_cb = (git_remote_create_cb)remoteCreateCallback;


Test patch:
diff --git a/diff_test.go b/diff_test.go
index 6fbad51..394a4c1 100644
--- a/diff_test.go
+++ b/diff_test.go
@@ -2,6 +2,9 @@ package git
 
 import (
 	"errors"
+	"fmt"
+	"io/ioutil"
+	"path"
 	"strings"
 	"testing"
 )
@@ -236,3 +239,307 @@ func TestDiffBlobs(t *testing.T) {
 		t.Fatalf("Bad number of lines iterated")
 	}
 }
+
+func TestApplyDiffAddfile(t *testing.T) {
+	repo := createTestRepo(t)
+	defer cleanupTestRepo(t, repo)
+
+	seedTestRepo(t, repo)
+
+	addFirstFileCommit, addFileTree := addAndGetTree(t, repo, "file1", `hello`)
+	addSecondFileCommit, addSecondFileTree := addAndGetTree(t, repo, "file2", `hello2`)
+
+	diff, err := repo.DiffTreeToTree(addFileTree, addSecondFileTree, nil)
+	checkFatal(t, err)
+
+	t.Run("check does not apply to current tree because file exists", func(t *testing.T) {
+		err = repo.ResetToCommit(addSecondFileCommit, ResetHard, &CheckoutOpts{})
+		checkFatal(t, err)
+
+		err = repo.ApplyDiff(diff, ApplyLocationBoth, nil)
+		if err == nil {
+			t.Error("expecting applying patch to current repo to fail")
+		}
+	})
+
+	t.Run("check apply to correct commit", func(t *testing.T) {
+		err = repo.ResetToCommit(addFirstFileCommit, ResetHard, &CheckoutOpts{})
+		checkFatal(t, err)
+
+		err = repo.ApplyDiff(diff, ApplyLocationBoth, nil)
+		checkFatal(t, err)
+
+		t.Run("Check that diff only changed one file", func(t *testing.T) {
+			checkSecondFileStaged(t, repo)
+
+			index, err := repo.Index()
+			checkFatal(t, err)
+			defer index.Free()
+
+			newTreeOID, err := index.WriteTreeTo(repo)
+			checkFatal(t, err)
+
+			newTree, err := repo.LookupTree(newTreeOID)
+			checkFatal(t, err)
+			defer newTree.Free()
+
+			_, err = repo.CreateCommit("HEAD", signature(), signature(), fmt.Sprintf("patch apply"), newTree, addFirstFileCommit)
+			checkFatal(t, err)
+		})
+
+		t.Run("test applying patch produced the same diff", func(t *testing.T) {
+			head, err := repo.Head()
+			checkFatal(t, err)
+
+			commit, err := repo.LookupCommit(head.Target())
+			checkFatal(t, err)
+
+			tree, err := commit.Tree()
+			checkFatal(t, err)
+
+			newDiff, err := repo.DiffTreeToTree(addFileTree, tree, nil)
+			checkFatal(t, err)
+
+			raw1b, err := diff.ToBuf(DiffFormatPatch)
+			checkFatal(t, err)
+			raw2b, err := newDiff.ToBuf(DiffFormatPatch)
+			checkFatal(t, err)
+
+			raw1 := string(raw1b)
+			raw2 := string(raw2b)
+
+			if raw1 != raw2 {
+				t.Error("diffs should be the same")
+			}
+		})
+	})
+
+	t.Run("check convert to raw buffer and apply", func(t *testing.T) {
+		err = repo.ResetToCommit(addFirstFileCommit, ResetHard, &CheckoutOpts{})
+		checkFatal(t, err)
+
+		raw, err := diff.ToBuf(DiffFormatPatch)
+		checkFatal(t, err)
+
+		if len(raw) == 0 {
+			t.Error("empty diff created")
+		}
+
+		diff2, err := DiffFromBuffer(raw, repo)
+		checkFatal(t, err)
+
+		err = repo.ApplyDiff(diff2, ApplyLocationBoth, nil)
+		checkFatal(t, err)
+	})
+
+	t.Run("check apply callbacks work", func(t *testing.T) {
+		// reset the state and get new default options for test
+		resetAndGetOpts := func(t *testing.T) *ApplyOptions {
+			err = repo.ResetToCommit(addFirstFileCommit, ResetHard, &CheckoutOpts{})
+			checkFatal(t, err)
+
+			opts, err := DefaultApplyOptions()
+			checkFatal(t, err)
+
+			return opts
+		}
+
+		t.Run("Check hunk callback working applies patch", func(t *testing.T) {
+			opts := resetAndGetOpts(t)
+
+			called := false
+			opts.ApplyHunkCallback = func(hunk *DiffHunk) (apply bool, err error) {
+				called = true
+				return true, nil
+			}
+
+			err = repo.ApplyDiff(diff, ApplyLocationBoth, opts)
+			checkFatal(t, err)
+
+			if called == false {
+				t.Error("apply hunk callback was not called")
+			}
+
+			checkSecondFileStaged(t, repo)
+		})
+
+		t.Run("Check delta callback working applies patch", func(t *testing.T) {
+			opts := resetAndGetOpts(t)
+
+			called := false
+			opts.ApplyDeltaCallback = func(hunk *DiffDelta) (apply bool, err error) {
+				if hunk.NewFile.Path != "file2" {
+					t.Error("Unexpected delta in diff application")
+				}
+				called = true
+				return true, nil
+			}
+
+			err = repo.ApplyDiff(diff, ApplyLocationBoth, opts)
+			checkFatal(t, err)
+
+			if called == false {
+				t.Error("apply hunk callback was not called")
+			}
+
+			checkSecondFileStaged(t, repo)
+		})
+
+		t.Run("Check delta callback returning false does not apply patch", func(t *testing.T) {
+			opts := resetAndGetOpts(t)
+
+			called := false
+			opts.ApplyDeltaCallback = func(hunk *DiffDelta) (apply bool, err error) {
+				if hunk.NewFile.Path != "file2" {
+					t.Error("Unexpected hunk in diff application")
+				}
+				called = true
+				return false, nil
+			}
+
+			err = repo.ApplyDiff(diff, ApplyLocationBoth, opts)
+			checkFatal(t, err)
+
+			if called == false {
+				t.Error("apply hunk callback was not called")
+			}
+
+			checkNoFilesStaged(t, repo)
+		})
+
+		t.Run("Check hunk callback returning causes application to fail", func(t *testing.T) {
+			opts := resetAndGetOpts(t)
+
+			called := false
+			opts.ApplyHunkCallback = func(hunk *DiffHunk) (apply bool, err error) {
+				called = true
+				return false, errors.New("something happened")
+			}
+
+			err = repo.ApplyDiff(diff, ApplyLocationBoth, opts)
+			if err == nil {
+				t.Error("expected an error after trying to apply")
+			}
+
+			if called == false {
+				t.Error("apply hunk callback was not called")
+			}
+
+			checkNoFilesStaged(t, repo)
+		})
+
+		t.Run("Check delta callback returning causes application to fail", func(t *testing.T) {
+			opts := resetAndGetOpts(t)
+
+			called := false
+			opts.ApplyDeltaCallback = func(hunk *DiffDelta) (apply bool, err error) {
+				if hunk.NewFile.Path != "file2" {
+					t.Error("Unexpected delta in diff application")
+				}
+				called = true
+				return false, errors.New("something happened")
+			}
+
+			err = repo.ApplyDiff(diff, ApplyLocationBoth, opts)
+			if err == nil {
+				t.Error("expected an error after trying to apply")
+			}
+
+			if called == false {
+				t.Error("apply hunk callback was not called")
+			}
+
+			checkNoFilesStaged(t, repo)
+		})
+	})
+}
+
+// checkSecondFileStaged checks that there is a single file called "file2" uncommitted in the repo
+func checkSecondFileStaged(t *testing.T, repo *Repository) {
+	opts := StatusOptions{
+		Show:  StatusShowIndexAndWorkdir,
+		Flags: StatusOptIncludeUntracked,
+	}
+
+	statuses, err := repo.StatusList(&opts)
+	checkFatal(t, err)
+
+	count, err := statuses.EntryCount()
+	checkFatal(t, err)
+
+	if count != 1 {
+		t.Error("diff should affect exactly one file")
+	}
+	if count == 0 {
+		t.Fatal("no statuses, cannot continue test")
+	}
+
+	entry, err := statuses.ByIndex(0)
+	checkFatal(t, err)
+
+	if entry.Status != StatusIndexNew {
+		t.Error("status should be 'new' as file has been added between commits")
+	}
+
+	if entry.HeadToIndex.NewFile.Path != "file2" {
+		t.Error("new file should be 'file2")
+	}
+	return
+}
+
+// checkNoFilesStaged checks that there is a single file called "file2" uncommitted in the repo
+func checkNoFilesStaged(t *testing.T, repo *Repository) {
+	opts := StatusOptions{
+		Show:  StatusShowIndexAndWorkdir,
+		Flags: StatusOptIncludeUntracked,
+	}
+
+	statuses, err := repo.StatusList(&opts)
+	checkFatal(t, err)
+
+	count, err := statuses.EntryCount()
+	checkFatal(t, err)
+
+	if count != 0 {
+		t.Error("files changed unexpectedly")
+	}
+}
+
+// addAndGetTree creates a file and commits it, returning the commit and tree
+func addAndGetTree(t *testing.T, repo *Repository, filename string, content string) (*Commit, *Tree) {
+	headCommit, err := headCommit(repo)
+	checkFatal(t, err)
+	defer headCommit.Free()
+
+	p := repo.Path()
+	p = strings.TrimSuffix(p, ".git")
+	p = strings.TrimSuffix(p, ".git/")
+
+	err = ioutil.WriteFile(path.Join(p, filename), []byte((content)), 0777)
+	checkFatal(t, err)
+
+	index, err := repo.Index()
+	checkFatal(t, err)
+	defer index.Free()
+
+	err = index.AddByPath(filename)
+	checkFatal(t, err)
+
+	newTreeOID, err := index.WriteTreeTo(repo)
+	checkFatal(t, err)
+
+	newTree, err := repo.LookupTree(newTreeOID)
+	checkFatal(t, err)
+	defer newTree.Free()
+
+	commitId, err := repo.CreateCommit("HEAD", signature(), signature(), fmt.Sprintf("add %s", filename), newTree, headCommit)
+	checkFatal(t, err)
+
+	commit, err := repo.LookupCommit(commitId)
+	checkFatal(t, err)
+
+	tree, err := commit.Tree()
+	checkFatal(t, err)
+
+	return commit, tree
+}