Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions save.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,15 @@ func (c *Cache) Save(ctx context.Context, cacheID string) (SaveResult, error) {

c.callProgress(cacheID, "validating", "Validating cache configuration", 0, 0)

// Validate cache paths exist
if err := checkPathsExist(cacheConfig.Paths); err != nil {
// Filter paths to only those that exist, collecting warnings for missing paths
validPaths, warnings, err := filterExistingPaths(cacheConfig.Paths)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "invalid cache paths")
return result, fmt.Errorf("invalid cache paths: %w", err)
}
result.Warnings = warnings
cacheConfig.Paths = validPaths

c.callProgress(cacheID, "checking_exists", "Checking if cache already exists", 0, 0)

Expand Down Expand Up @@ -256,29 +259,37 @@ func (c *Cache) Save(ctx context.Context, cacheID string) (SaveResult, error) {
return result, nil
}

// checkPathsExist validates that all paths exist on the filesystem
func checkPathsExist(paths []string) error {
// filterExistingPaths validates paths and returns only those that exist.
// Missing paths are returned as warnings rather than causing errors.
func filterExistingPaths(paths []string) (validPaths []string, warnings []string, err error) {
if len(paths) == 0 {
return fmt.Errorf("no paths provided")
return nil, nil, fmt.Errorf("no paths provided")
}

for _, path := range paths {
expandedPath := path
// Handle ~ expansion
if len(path) > 0 && path[0] == '~' {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home directory: %w", err)
return nil, nil, fmt.Errorf("failed to get home directory: %w", err)
}
path = homeDir + path[1:]
expandedPath = homeDir + path[1:]
}

// Check if the path exists
if _, err := os.Stat(path); os.IsNotExist(err) {
return fmt.Errorf("path does not exist: %s", path)
if _, err := os.Stat(expandedPath); os.IsNotExist(err) {
warnings = append(warnings, fmt.Sprintf("path does not exist: %s", path))
} else {
validPaths = append(validPaths, path)
}
}

return nil
if len(validPaths) == 0 {
return nil, warnings, fmt.Errorf("no valid paths found")
}

return validPaths, warnings, nil
}

// validateCacheStore validates the cache store configuration
Expand Down
96 changes: 96 additions & 0 deletions save_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package zstash

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestFilterExistingPaths(t *testing.T) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an implementation detail. I wonder if we should start by adding an test case for the Save func instead of this internal function?

t.Run("returns all paths when all exist", func(t *testing.T) {
dir := t.TempDir()
file1 := filepath.Join(dir, "file1.txt")
file2 := filepath.Join(dir, "file2.txt")
require.NoError(t, os.WriteFile(file1, []byte("test1"), 0o644))
require.NoError(t, os.WriteFile(file2, []byte("test2"), 0o644))

validPaths, warnings, err := filterExistingPaths([]string{file1, file2})

require.NoError(t, err)
assert.Equal(t, []string{file1, file2}, validPaths)
assert.Empty(t, warnings)
})

t.Run("returns warning for missing path", func(t *testing.T) {
dir := t.TempDir()
existingFile := filepath.Join(dir, "exists.txt")
missingFile := filepath.Join(dir, "missing.txt")
require.NoError(t, os.WriteFile(existingFile, []byte("test"), 0o644))

validPaths, warnings, err := filterExistingPaths([]string{existingFile, missingFile})

require.NoError(t, err)
assert.Equal(t, []string{existingFile}, validPaths)
assert.Len(t, warnings, 1)
assert.Contains(t, warnings[0], "path does not exist")
assert.Contains(t, warnings[0], missingFile)
})

t.Run("returns error when all paths missing", func(t *testing.T) {
validPaths, warnings, err := filterExistingPaths([]string{"/nonexistent/path1", "/nonexistent/path2"})

require.Error(t, err)
assert.Contains(t, err.Error(), "no valid paths found")
assert.Nil(t, validPaths)
assert.Len(t, warnings, 2)
})

t.Run("returns error for empty paths", func(t *testing.T) {
validPaths, warnings, err := filterExistingPaths([]string{})

require.Error(t, err)
assert.Contains(t, err.Error(), "no paths provided")
assert.Nil(t, validPaths)
assert.Nil(t, warnings)
})

t.Run("handles directories", func(t *testing.T) {
dir := t.TempDir()
subdir := filepath.Join(dir, "subdir")
require.NoError(t, os.MkdirAll(subdir, 0o755))

validPaths, warnings, err := filterExistingPaths([]string{subdir})

require.NoError(t, err)
assert.Equal(t, []string{subdir}, validPaths)
assert.Empty(t, warnings)
})

t.Run("handles tilde expansion", func(t *testing.T) {
homeDir, err := os.UserHomeDir()
require.NoError(t, err)

// Create a temp file in home directory for this test
tempFile := filepath.Join(homeDir, ".zstash_test_temp")
require.NoError(t, os.WriteFile(tempFile, []byte("test"), 0o644))
defer os.Remove(tempFile)

validPaths, warnings, err := filterExistingPaths([]string{"~/.zstash_test_temp"})

require.NoError(t, err)
assert.Equal(t, []string{"~/.zstash_test_temp"}, validPaths)
assert.Empty(t, warnings)
})

t.Run("warns for missing tilde path", func(t *testing.T) {
validPaths, warnings, err := filterExistingPaths([]string{"~/.nonexistent_zstash_test_path", "/tmp"})

require.NoError(t, err)
assert.Equal(t, []string{"/tmp"}, validPaths)
assert.Len(t, warnings, 1)
assert.Contains(t, warnings[0], "~/.nonexistent_zstash_test_path")
})
}
9 changes: 9 additions & 0 deletions zstash.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,15 @@ type SaveResult struct {
// TotalDuration is the end-to-end duration of the save operation,
// from validation through commit (if created) or early exit (if exists).
TotalDuration time.Duration

// Warnings contains non-fatal issues encountered during the save operation,
// such as paths that were configured but did not exist.
Warnings []string
}

// HasWarnings one or more warnings are present in the result
func (sr *SaveResult) HasWarnings() bool {
return len(sr.Warnings) > 0
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not a blocker but I don't anticipate caller side can do anything beyond logging for these warnings. Do you want to consider allowing zstash to take a slog.Logger object and handle logging internally?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@zhming0 You make a good point, I was tossing up what to do here.

I am really just putting off solving the logger problem, I need to either pass in the slog logger in https://pkg.go.dev/github.com/buildkite/zstash#NewCache or propegate it via the context.


// RestoreResult contains detailed information about a cache restore operation.
Expand Down