diff --git a/repository.go b/repository.go index 5bdaacd..5c3a2c2 100644 --- a/repository.go +++ b/repository.go @@ -36,6 +36,9 @@ type Repository struct { // Stashes represents the collection of stashes and can be used to // save, apply and iterate over stash states in this repository. Stashes StashCollection + // Worktrees represents the collection of worktrees and can be used to + // add, list and remove worktrees for this repository + Worktrees WorktreeCollection // weak indicates that a repository is a weak pointer and should not be // freed. @@ -52,6 +55,7 @@ func newRepositoryFromC(ptr *C.git_repository) *Repository { repo.Notes.repo = repo repo.Tags.repo = repo repo.Stashes.repo = repo + repo.Worktrees.repo = repo runtime.SetFinalizer(repo, (*Repository).Free) diff --git a/worktree.go b/worktree.go new file mode 100644 index 0000000..60eae09 --- /dev/null +++ b/worktree.go @@ -0,0 +1,271 @@ +package git + +/* +#include +*/ +import "C" +import ( + "runtime" + "unsafe" +) + +type WorktreeCollection struct { + doNotCompare + repo *Repository +} + +type Worktree struct { + doNotCompare + ptr *C.git_worktree +} + +type AddWorktreeOptions struct { + // Lock the newly created worktree + Lock bool + // Reference to use for the new worktree + Reference *Reference + // CheckoutOptions is used for configuring the checkout for the newly created worktree + CheckoutOptions CheckoutOptions +} + +// Add adds a new working tree for the given repository +func (c *WorktreeCollection) Add(name string, path string, options *AddWorktreeOptions) (*Worktree, error) { + cName := C.CString(name) + defer C.free(unsafe.Pointer(cName)) + + cPath := C.CString(path) + defer C.free(unsafe.Pointer(cPath)) + + var err error + cOptions := populateAddWorktreeOptions(&C.git_worktree_add_options{}, options, &err) + defer freeAddWorktreeOptions(cOptions) + + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + var ptr *C.git_worktree + ret := C.git_worktree_add(&ptr, c.repo.ptr, cName, cPath, cOptions) + runtime.KeepAlive(c) + if options != nil && options.Reference != nil { + runtime.KeepAlive(options.Reference) + } + + if ret == C.int(ErrorCodeUser) && err != nil { + return nil, err + } else if ret < 0 { + return nil, MakeGitError(ret) + } + return newWorktreeFromC(ptr), nil +} + +// List lists names of linked working trees for the given repository +func (c *WorktreeCollection) List() ([]string, error) { + var strC C.git_strarray + defer C.git_strarray_dispose(&strC) + + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + ret := C.git_worktree_list(&strC, c.repo.ptr) + runtime.KeepAlive(c) + if ret < 0 { + return nil, MakeGitError(ret) + } + + w := makeStringsFromCStrings(strC.strings, int(strC.count)) + return w, nil +} + +// Lookup gets a working tree by its name for the given repository +func (c *WorktreeCollection) Lookup(name string) (*Worktree, error) { + cname := C.CString(name) + defer C.free(unsafe.Pointer(cname)) + + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + var ptr *C.git_worktree + ret := C.git_worktree_lookup(&ptr, c.repo.ptr, cname) + runtime.KeepAlive(c) + + if ret < 0 { + return nil, MakeGitError(ret) + } else if ptr == nil { + return nil, nil + } + return newWorktreeFromC(ptr), nil +} + +// OpenFromRepository retrieves a worktree for the given repository +func (c *WorktreeCollection) OpenFromRepository() (*Worktree, error) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + var ptr *C.git_worktree + ret := C.git_worktree_open_from_repository(&ptr, c.repo.ptr) + runtime.KeepAlive(c) + + if ret < 0 { + return nil, MakeGitError(ret) + } + return newWorktreeFromC(ptr), nil +} + +func newWorktreeFromC(ptr *C.git_worktree) *Worktree { + worktree := &Worktree{ptr: ptr} + runtime.SetFinalizer(worktree, (*Worktree).Free) + return worktree +} + +func freeAddWorktreeOptions(cOptions *C.git_worktree_add_options) { + if cOptions == nil { + return + } + freeCheckoutOptions(&cOptions.checkout_options) +} + +func populateAddWorktreeOptions(cOptions *C.git_worktree_add_options, options *AddWorktreeOptions, errorTarget *error) *C.git_worktree_add_options { + C.git_worktree_add_options_init(cOptions, C.GIT_WORKTREE_ADD_OPTIONS_VERSION) + if options == nil { + return nil + } + + populateCheckoutOptions(&cOptions.checkout_options, &options.CheckoutOptions, errorTarget) + cOptions.lock = cbool(options.Lock) + if options.Reference != nil { + cOptions.ref = options.Reference.ptr + } + return cOptions +} + +// Free a previously allocated worktree +func (w *Worktree) Free() { + runtime.SetFinalizer(w, nil) + C.git_worktree_free(w.ptr) +} + +// IsLocked checks if the given worktree is locked +func (w *Worktree) IsLocked() (locked bool, reason string, err error) { + buf := C.git_buf{} + defer C.git_buf_dispose(&buf) + + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + ret := C.git_worktree_is_locked(&buf, w.ptr) + runtime.KeepAlive(w) + + if ret < 0 { + return false, "", MakeGitError(ret) + } + return ret != 0, C.GoString(buf.ptr), nil +} + +type WorktreePruneFlag uint32 + +const ( + // WorktreePruneValid means prune working tree even if working tree is valid + WorktreePruneValid WorktreePruneFlag = C.GIT_WORKTREE_PRUNE_VALID + // WorktreePruneLocked means prune working tree even if it is locked + WorktreePruneLocked WorktreePruneFlag = C.GIT_WORKTREE_PRUNE_LOCKED + // WorktreePruneWorkingTree means prune checked out working tree + WorktreePruneWorkingTree WorktreePruneFlag = C.GIT_WORKTREE_PRUNE_WORKING_TREE +) + +// IsPrunable checks that the worktree is prunable with the given flags +func (w *Worktree) IsPrunable(flags WorktreePruneFlag) (bool, error) { + cOptions := C.git_worktree_prune_options{} + C.git_worktree_prune_options_init(&cOptions, C.GIT_WORKTREE_PRUNE_OPTIONS_VERSION) + cOptions.flags = C.uint32_t(flags) + + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + ret := C.git_worktree_is_prunable(w.ptr, &cOptions) + runtime.KeepAlive(w) + + if ret < 0 { + return false, MakeGitError(ret) + } + return ret != 0, nil +} + +// Lock locks the worktree if not already locked +func (w *Worktree) Lock(reason string) error { + var cReason *C.char + if reason != "" { + cReason = C.CString(reason) + defer C.free(unsafe.Pointer(cReason)) + } + + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + ret := C.git_worktree_lock(w.ptr, cReason) + runtime.KeepAlive(w) + + if ret < 0 { + return MakeGitError(ret) + } + return nil +} + +// Name retrieves the name of the worktree +func (w *Worktree) Name() string { + s := C.GoString(C.git_worktree_name(w.ptr)) + runtime.KeepAlive(w) + return s +} + +// Path retrieves the path of the worktree +func (w *Worktree) Path() string { + s := C.GoString(C.git_worktree_path(w.ptr)) + runtime.KeepAlive(w) + return s +} + +// Prune the worktree with the provided flags +func (w *Worktree) Prune(flags WorktreePruneFlag) error { + cOptions := C.git_worktree_prune_options{} + C.git_worktree_prune_options_init(&cOptions, C.GIT_WORKTREE_PRUNE_OPTIONS_VERSION) + cOptions.flags = C.uint32_t(flags) + + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + ret := C.git_worktree_prune(w.ptr, &cOptions) + runtime.KeepAlive(w) + + if ret < 0 { + return MakeGitError(ret) + } + return nil +} + +// Unlock a locked worktree +func (w *Worktree) Unlock() (notLocked bool, err error) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + ret := C.git_worktree_unlock(w.ptr) + runtime.KeepAlive(w) + + if ret < 0 { + return false, MakeGitError(ret) + } + return ret != 0, nil +} + +// Validate checks if the given worktree is valid +func (w *Worktree) Validate() error { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + ret := C.git_worktree_validate(w.ptr) + runtime.KeepAlive(w) + + if ret < 0 { + return MakeGitError(ret) + } + return nil +} diff --git a/worktree_test.go b/worktree_test.go new file mode 100644 index 0000000..cbd1605 --- /dev/null +++ b/worktree_test.go @@ -0,0 +1,225 @@ +package git + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" +) + +func assertSamePath(t *testing.T, expected string, actual string) { + var err error + expected, err = filepath.EvalSymlinks(expected) + checkFatal(t, err) + actual, err = filepath.EvalSymlinks(actual) + checkFatal(t, err) + + if expected != actual { + t.Fatalf("wrong path (expected %s, got %s)", expected, actual) + } +} + +func TestAddWorkspace(t *testing.T) { + t.Parallel() + repo := createTestRepo(t) + defer cleanupTestRepo(t, repo) + seedTestRepo(t, repo) + + worktreeTemporaryPath, err := ioutil.TempDir("", "git2go") + checkFatal(t, err) + defer func() { checkFatal(t, os.RemoveAll(worktreeTemporaryPath)) }() + worktreeName := "testWorktree" + worktreePath := filepath.Join(worktreeTemporaryPath, "worktree") + + worktree, err := repo.Worktrees.Add(worktreeName, worktreePath, &AddWorktreeOptions{ + Lock: true, CheckoutOptions: CheckoutOptions{Strategy: CheckoutForce}, + }) + checkFatal(t, err) + + if name := worktree.Name(); name != worktreeName { + t.Fatalf("wrong worktree name: %s != %s", worktreeName, name) + } + locked, _, err := worktree.IsLocked() + checkFatal(t, err) + if locked != true { + t.Fatal("worktree isn't locked") + } + assertSamePath(t, worktreePath, worktree.Path()) + checkFatal(t, worktree.Validate()) +} + +func TestAddWorkspaceWithoutOptions(t *testing.T) { + t.Parallel() + repo := createTestRepo(t) + defer cleanupTestRepo(t, repo) + seedTestRepo(t, repo) + + worktreeTemporaryPath, err := ioutil.TempDir("", "git2go") + checkFatal(t, err) + defer func() { checkFatal(t, os.RemoveAll(worktreeTemporaryPath)) }() + worktreeName := "testWorktree" + worktreePath := filepath.Join(worktreeTemporaryPath, "worktree") + + worktree, err := repo.Worktrees.Add(worktreeName, worktreePath, nil) + checkFatal(t, err) + + if name := worktree.Name(); name != worktreeName { + t.Fatalf("wrong worktree name: %s != %s", worktreeName, name) + } + locked, _, err := worktree.IsLocked() + checkFatal(t, err) + if locked != false { + t.Fatal("worktree is locked") + } + assertSamePath(t, worktreePath, worktree.Path()) + checkFatal(t, worktree.Validate()) +} + +func TestLookupWorkspace(t *testing.T) { + t.Parallel() + repo := createTestRepo(t) + defer cleanupTestRepo(t, repo) + seedTestRepo(t, repo) + + worktreeTemporaryPath, err := ioutil.TempDir("", "git2go") + checkFatal(t, err) + defer func() { checkFatal(t, os.RemoveAll(worktreeTemporaryPath)) }() + worktreeName := "testWorktree" + + worktree, err := repo.Worktrees.Add(worktreeName, filepath.Join(worktreeTemporaryPath, "worktree"), nil) + checkFatal(t, err) + retrievedWorktree, err := repo.Worktrees.Lookup(worktreeName) + checkFatal(t, err) + + assertSamePath(t, worktree.Path(), retrievedWorktree.Path()) +} + +func TestListWorkspaces(t *testing.T) { + t.Parallel() + repo := createTestRepo(t) + defer cleanupTestRepo(t, repo) + seedTestRepo(t, repo) + + worktreeTemporaryPath, err := ioutil.TempDir("", "git2go") + checkFatal(t, err) + defer func() { checkFatal(t, os.RemoveAll(worktreeTemporaryPath)) }() + + worktreeNames := []string{"worktree1", "worktree2", "worktree3"} + for _, name := range worktreeNames { + _, err = repo.Worktrees.Add(name, filepath.Join(worktreeTemporaryPath, name), nil) + checkFatal(t, err) + } + listedWorktree, err := repo.Worktrees.List() + checkFatal(t, err) + + if len(worktreeNames) != len(listedWorktree) { + t.Fatalf("len(worktreeNames) != len(listedWorktree) as %d != %d", len(worktreeNames), len(listedWorktree)) + } + for _, name := range worktreeNames { + found := false + for _, nameToMatch := range listedWorktree { + if name == nameToMatch { + found = true + break + } + } + + if !found { + t.Fatalf("worktree %s is missing", name) + } + } +} + +func TestOpenWorkspaceFromRepository(t *testing.T) { + t.Parallel() + repo := createTestRepo(t) + defer cleanupTestRepo(t, repo) + seedTestRepo(t, repo) + + worktreeTemporaryPath, err := ioutil.TempDir("", "git2go") + checkFatal(t, err) + defer func() { checkFatal(t, os.RemoveAll(worktreeTemporaryPath)) }() + + worktree, err := repo.Worktrees.Add("testWorktree", filepath.Join(worktreeTemporaryPath, "worktree"), nil) + checkFatal(t, err) + worktreeRepo, err := OpenRepository(worktree.Path()) + checkFatal(t, err) + worktreeFromRepo, err := worktreeRepo.Worktrees.OpenFromRepository() + checkFatal(t, err) + + if worktreeFromRepo.Name() != worktree.Name() { + t.Fatalf("wrong name (expected %s, got %s)", worktreeFromRepo.Name(), worktree.Name()) + } +} + +func TestWorktreeIsPrunable(t *testing.T) { + t.Parallel() + repo := createTestRepo(t) + defer cleanupTestRepo(t, repo) + seedTestRepo(t, repo) + + worktreeTemporaryPath, err := ioutil.TempDir("", "git2go") + checkFatal(t, err) + defer func() { checkFatal(t, os.RemoveAll(worktreeTemporaryPath)) }() + + worktree, err := repo.Worktrees.Add("testWorktree", filepath.Join(worktreeTemporaryPath, "worktree"), nil) + checkFatal(t, err) + err = worktree.Lock("test") + checkFatal(t, err) + + isPrunableWithoutLockedFlag, err := worktree.IsPrunable(WorktreePruneValid) + checkFatal(t, err) + if isPrunableWithoutLockedFlag { + t.Fatal("worktree shouldn't be prunable without the WorktreePruneLocked flag") + } + isPrunableWithLockedFlag, err := worktree.IsPrunable(WorktreePruneValid | WorktreePruneLocked) + checkFatal(t, err) + if !isPrunableWithLockedFlag { + t.Fatal("worktree should be prunable with the WorktreePruneLocked flag") + } + + err = worktree.Prune(WorktreePruneValid | WorktreePruneLocked) + checkFatal(t, err) +} + +func TestWorktreeCanBeLockedAndUnlocked(t *testing.T) { + t.Parallel() + repo := createTestRepo(t) + defer cleanupTestRepo(t, repo) + seedTestRepo(t, repo) + + worktreeTemporaryPath, err := ioutil.TempDir("", "git2go") + checkFatal(t, err) + defer func() { checkFatal(t, os.RemoveAll(worktreeTemporaryPath)) }() + + worktree, err := repo.Worktrees.Add("testWorktree", filepath.Join(worktreeTemporaryPath, "worktree"), nil) + checkFatal(t, err) + notLocked, err := worktree.Unlock() + checkFatal(t, err) + if !notLocked { + t.Fatal("worktree should be unlocked by default") + } + + expectedReason := "toTestIt" + err = worktree.Lock(expectedReason) + checkFatal(t, err) + isLocked, reason, err := worktree.IsLocked() + checkFatal(t, err) + if !isLocked { + t.Fatal("worktree should be locked after the locking operation") + } + if expectedReason != reason { + t.Fatalf("locked reason doesn't match: %s != %s", expectedReason, reason) + } + + notLocked, err = worktree.Unlock() + checkFatal(t, err) + if notLocked { + t.Fatal("worktree was lock before so notLocked should be false") + } + isLocked, _, err = worktree.IsLocked() + checkFatal(t, err) + if isLocked { + t.Fatal("worktree should be unlocked after the Unlock() call") + } +}