Repository: go-git/go-git (go)

Issue:
SparseCheckoutDirectories behavior when directory path does not exist
When checking out with `SparseCheckoutDirectories` pointing to a set of directories, what is the expected behavior if the directory path does not exist? 

 The observed behavior is that the checkout succeeds silently, is this by-design? Thanks.

Pull request description:
Add sparse reset directory validation and remove ResetSparsely.
Fixes #1500 

1. Added a validation to check if directory exists in the commit tree.
2. Removed `ResetSparsely` and moved `dirs` into `ResetOptions`.
3. Added a flag to disable validation. See [this comment](https://github.com/go-git/go-git/issues/1500#issuecomment-2817634195).


Patch:
diff --git a/options.go b/options.go
index 9040c837..f61c8e26 100644
--- a/options.go
+++ b/options.go
@@ -425,6 +425,13 @@ type ResetOptions struct {
 	// Files, if not empty will constrain the reseting the index to only files
 	// specified in this list.
 	Files []string
+
+	// SparseDirs specifies which directories should be checked out.
+	// Directories not listed here will not appear in the worktree.
+	SparseDirs []string
+
+	// SkipSparseDirValidation will skip the validation for SparseDirs.
+	SkipSparseDirValidation bool
 }
 
 // Validate validates the fields and sets the default values.
diff --git a/worktree.go b/worktree.go
index 74b5f805..3120e098 100644
--- a/worktree.go
+++ b/worktree.go
@@ -33,6 +33,7 @@ var (
 	ErrGitModulesSymlink               = errors.New(gitmodulesFile + " is a symlink")
 	ErrNonFastForwardUpdate            = errors.New("non-fast-forward update")
 	ErrRestoreWorktreeOnlyNotSupported = errors.New("worktree only is not supported")
+	ErrSparseResetDirectoryNotFound    = errors.New("sparse-reset directory not found on commit")
 )
 
 // Worktree represents a git worktree.
@@ -177,7 +178,11 @@ func (w *Worktree) Checkout(opts *CheckoutOptions) error {
 		return err
 	}
 
-	ro := &ResetOptions{Commit: c, Mode: MergeReset}
+	ro := &ResetOptions{
+		Commit:     c,
+		Mode:       MergeReset,
+		SparseDirs: opts.SparseCheckoutDirectories,
+	}
 	if opts.Force {
 		ro.Mode = HardReset
 	} else if opts.Keep {
@@ -194,10 +199,6 @@ func (w *Worktree) Checkout(opts *CheckoutOptions) error {
 		return err
 	}
 
-	if len(opts.SparseCheckoutDirectories) > 0 {
-		return w.ResetSparsely(ro, opts.SparseCheckoutDirectories)
-	}
-
 	return w.Reset(ro)
 }
 
@@ -280,7 +281,13 @@ func (w *Worktree) setHEADToBranch(branch plumbing.ReferenceName, commit plumbin
 	return w.r.Storer.SetReference(head)
 }
 
-func (w *Worktree) ResetSparsely(opts *ResetOptions, dirs []string) error {
+// Reset the worktree to a specified state.
+func (w *Worktree) Reset(opts *ResetOptions) error {
+	start := time.Now()
+	defer func() {
+		trace.Performance.Printf("performance: %.9f s: reset_worktree", time.Since(start).Seconds())
+	}()
+
 	if err := opts.Validate(w.r); err != nil {
 		return err
 	}
@@ -296,12 +303,8 @@ func (w *Worktree) ResetSparsely(opts *ResetOptions, dirs []string) error {
 		}
 	}
 
-	if err := w.setHEADCommit(opts.Commit); err != nil {
-		return err
-	}
-
 	if opts.Mode == SoftReset {
-		return nil
+		return w.setHEADCommit(opts.Commit)
 	}
 
 	t, err := w.r.getTreeFromCommitHash(opts.Commit)
@@ -309,8 +312,18 @@ func (w *Worktree) ResetSparsely(opts *ResetOptions, dirs []string) error {
 		return err
 	}
 
+	if len(opts.SparseDirs) > 0 && !opts.SkipSparseDirValidation {
+		if !treeContainsDirs(t, opts.SparseDirs) {
+			return ErrSparseResetDirectoryNotFound
+		}
+	}
+
+	if err := w.setHEADCommit(opts.Commit); err != nil {
+		return err
+	}
+
 	if opts.Mode == MixedReset || opts.Mode == MergeReset || opts.Mode == HardReset {
-		if err := w.resetIndex(t, dirs, opts.Files); err != nil {
+		if err := w.resetIndex(t, opts.SparseDirs, opts.Files); err != nil {
 			return err
 		}
 	}
@@ -324,6 +337,26 @@ func (w *Worktree) ResetSparsely(opts *ResetOptions, dirs []string) error {
 	return nil
 }
 
+// treeContainsDirs checks if the given tree contains all the directories.
+// if dirs is empty, it returns false.
+func treeContainsDirs(tree *object.Tree, dirs []string) bool {
+	if len(dirs) == 0 {
+		return false
+	}
+
+	for _, dir := range dirs {
+		entry, err := tree.FindEntry(dir)
+		if err != nil {
+			return false
+		}
+		if entry.Mode != filemode.Dir {
+			return false
+		}
+	}
+
+	return true
+}
+
 // Restore restores specified files in the working tree or stage with contents from
 // a restore source. If a path is tracked but does not exist in the restore,
 // source, it will be removed to match the source.
@@ -359,16 +392,6 @@ func (w *Worktree) Restore(o *RestoreOptions) error {
 	return ErrRestoreWorktreeOnlyNotSupported
 }
 
-// Reset the worktree to a specified state.
-func (w *Worktree) Reset(opts *ResetOptions) error {
-	start := time.Now()
-	defer func() {
-		trace.Performance.Printf("performance: %.9f s: reset_worktree", time.Since(start).Seconds())
-	}()
-
-	return w.ResetSparsely(opts, nil)
-}
-
 func (w *Worktree) resetIndex(t *object.Tree, dirs []string, files []string) error {
 	idx, err := w.r.Storer.Index()
 	if err != nil {


Test patch:
diff --git a/worktree_test.go b/worktree_test.go
index 4632778e..9d77d6fd 100644
--- a/worktree_test.go
+++ b/worktree_test.go
@@ -1352,7 +1352,7 @@ func (s *WorktreeSuite) TestResetSparsely() {
 
 	sparseResetDirs := []string{"php"}
 
-	err := w.ResetSparsely(&ResetOptions{Mode: HardReset}, sparseResetDirs)
+	err := w.Reset(&ResetOptions{Mode: HardReset, SparseDirs: sparseResetDirs})
 	s.NoError(err)
 
 	files, err := fs.ReadDir("/")
@@ -1366,6 +1366,47 @@ func (s *WorktreeSuite) TestResetSparsely() {
 	s.Equal("crappy.php", files[0].Name())
 }
 
+func (s *WorktreeSuite) TestResetSparselyInvalidDir() {
+	fs := memfs.New()
+	w := &Worktree{
+		r:          s.Repository,
+		Filesystem: fs,
+	}
+
+	tests := []struct {
+		name    string
+		opts    ResetOptions
+		wantErr bool
+	}{
+		{
+			name:    "non existent directory",
+			opts:    ResetOptions{SparseDirs: []string{"non-existent"}},
+			wantErr: true,
+		},
+		{
+			name:    "exists but is not directory",
+			opts:    ResetOptions{SparseDirs: []string{"php/crappy.php"}},
+			wantErr: true,
+		},
+		{
+			name:    "skip validation for non existent directory",
+			opts:    ResetOptions{SparseDirs: []string{"non-existent"}, SkipSparseDirValidation: true},
+			wantErr: false,
+		},
+	}
+
+	for _, test := range tests {
+		s.Run(test.name, func() {
+			err := w.Reset(&test.opts)
+			if test.wantErr {
+				s.Require().ErrorIs(err, ErrSparseResetDirectoryNotFound)
+				return
+			}
+			s.Require().NoError(err)
+		})
+	}
+}
+
 func (s *WorktreeSuite) TestStatusAfterCheckout() {
 	fs := memfs.New()
 	w := &Worktree{
@@ -3126,6 +3167,50 @@ func (s *WorktreeSuite) TestLinkedWorktree() {
 	}
 }
 
+func TestTreeContainsDirs(t *testing.T) {
+	tree := &object.Tree{
+		Entries: []object.TreeEntry{
+			{Name: "foo", Mode: filemode.Dir},
+			{Name: "bar", Mode: filemode.Dir},
+			{Name: "baz", Mode: filemode.Dir},
+			{Name: "this-is-regular", Mode: filemode.Regular},
+		},
+	}
+
+	tests := []struct {
+		name     string
+		dirs     []string
+		expected bool
+	}{
+		{
+			name:     "example",
+			dirs:     []string{"foo", "baz"},
+			expected: true,
+		},
+		{
+			name:     "empty directories",
+			dirs:     []string{},
+			expected: false,
+		},
+		{
+			name:     "non existent directory",
+			dirs:     []string{"foobarbaz"},
+			expected: false,
+		},
+		{
+			name:     "exists but is not directory",
+			dirs:     []string{"this-is-regular"},
+			expected: false,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			assert.Equal(t, test.expected, treeContainsDirs(tree, test.dirs))
+		})
+	}
+}
+
 func TestValidPath(t *testing.T) {
 	type testcase struct {
 		path    string