diff --git a/internal/fs/fs.go b/internal/fs/fs.go index 583fe7d1e3..dab25a6441 100644 --- a/internal/fs/fs.go +++ b/internal/fs/fs.go @@ -37,6 +37,10 @@ func Exists(path string) bool { } func FindDown(dir, filename string) []string { + return FindDownWithExcludes(dir, filename, []string{}) +} + +func FindDownWithExcludes(dir, filename string, excludePatterns []string) []string { var result []string filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { @@ -45,9 +49,14 @@ func FindDown(dir, filename string) []string { } if info.IsDir() { name := info.Name() + // Always exclude node_modules and dot directories if name == "node_modules" || strings.HasPrefix(name, ".") { return filepath.SkipDir } + // Check custom exclude patterns + if shouldExclude(name, excludePatterns) { + return filepath.SkipDir + } } if !info.IsDir() && info.Name() == filename { result = append(result, path) @@ -57,3 +66,22 @@ func FindDown(dir, filename string) []string { return result } + +// shouldExclude checks if a directory name matches any of the exclude patterns +func shouldExclude(name string, patterns []string) bool { + for _, pattern := range patterns { + // Support glob patterns + matched, err := filepath.Match(pattern, name) + if err != nil { + // If pattern is invalid, try exact match as fallback + if name == pattern { + return true + } + continue + } + if matched { + return true + } + } + return false +} diff --git a/internal/fs/fs_test.go b/internal/fs/fs_test.go new file mode 100644 index 0000000000..4904810fbe --- /dev/null +++ b/internal/fs/fs_test.go @@ -0,0 +1,170 @@ +package fs_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sst/sst/v3/internal/fs" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindDown_WithExcludePatterns(t *testing.T) { + tests := []struct { + name string + setupDirs []string + setupFiles []string + searchFile string + excludePatterns []string + expectedCount int + shouldContain []string + shouldNotContain []string + }{ + { + name: "basic - no excludes", + setupDirs: []string{"src", "lib"}, + setupFiles: []string{"src/package.json", "lib/package.json"}, + searchFile: "package.json", + excludePatterns: []string{}, + expectedCount: 2, + shouldContain: []string{"src/package.json", "lib/package.json"}, + shouldNotContain: []string{}, + }, + { + name: "exclude node_modules", + setupDirs: []string{"src", "node_modules", "node_modules/lib"}, + setupFiles: []string{"src/package.json", "node_modules/package.json", "node_modules/lib/package.json"}, + searchFile: "package.json", + excludePatterns: []string{"node_modules"}, + expectedCount: 1, + shouldContain: []string{"src/package.json"}, + shouldNotContain: []string{"node_modules"}, + }, + { + name: "exclude external directory", + setupDirs: []string{"src", "external", "external/submodule"}, + setupFiles: []string{"src/package.json", "external/package.json", "external/submodule/package.json"}, + searchFile: "package.json", + excludePatterns: []string{"external"}, + expectedCount: 1, + shouldContain: []string{"src/package.json"}, + shouldNotContain: []string{"external"}, + }, + { + name: "exclude multiple patterns", + setupDirs: []string{"src", "external", "vendor", "lib"}, + setupFiles: []string{"src/package.json", "external/package.json", "vendor/package.json", "lib/package.json"}, + searchFile: "package.json", + excludePatterns: []string{"external", "vendor"}, + expectedCount: 2, + shouldContain: []string{"src/package.json", "lib/package.json"}, + shouldNotContain: []string{"external", "vendor"}, + }, + { + name: "exclude with glob pattern", + setupDirs: []string{"src", "test-fixtures", "test-data", "lib"}, + setupFiles: []string{"src/package.json", "test-fixtures/package.json", "test-data/package.json", "lib/package.json"}, + searchFile: "package.json", + excludePatterns: []string{"test-*"}, + expectedCount: 2, + shouldContain: []string{"src/package.json", "lib/package.json"}, + shouldNotContain: []string{"test-fixtures", "test-data"}, + }, + { + name: "exclude nested directories", + setupDirs: []string{"src", "src/external", "src/external/deep", "lib"}, + setupFiles: []string{"src/package.json", "src/external/package.json", "src/external/deep/package.json", "lib/package.json"}, + searchFile: "package.json", + excludePatterns: []string{"external"}, + expectedCount: 2, + shouldContain: []string{"src/package.json", "lib/package.json"}, + shouldNotContain: []string{"external"}, + }, + { + name: "existing node_modules still excluded by default", + setupDirs: []string{"src", "node_modules"}, + setupFiles: []string{"src/package.json", "node_modules/package.json"}, + searchFile: "package.json", + excludePatterns: []string{}, // No custom excludes + expectedCount: 1, + shouldContain: []string{"src/package.json"}, + shouldNotContain: []string{"node_modules"}, + }, + { + name: "existing dotfiles still excluded by default", + setupDirs: []string{"src", ".git", ".github"}, + setupFiles: []string{"src/package.json", ".git/package.json", ".github/package.json"}, + searchFile: "package.json", + excludePatterns: []string{}, // No custom excludes + expectedCount: 1, + shouldContain: []string{"src/package.json"}, + shouldNotContain: []string{".git", ".github"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + + // Setup directory structure + for _, dir := range tt.setupDirs { + err := os.MkdirAll(filepath.Join(tmpDir, dir), 0755) + require.NoError(t, err, "Failed to create directory: %s", dir) + } + + // Create files + for _, file := range tt.setupFiles { + fullPath := filepath.Join(tmpDir, file) + f, err := os.Create(fullPath) + require.NoError(t, err, "Failed to create file: %s", file) + f.Close() + } + + // Execute - this will fail until we implement the feature + results := fs.FindDownWithExcludes(tmpDir, tt.searchFile, tt.excludePatterns) + + // Assert count + assert.Len(t, results, tt.expectedCount, "Expected %d results, got %d", tt.expectedCount, len(results)) + + // Assert contains expected paths + for _, expectedPath := range tt.shouldContain { + assert.True(t, containsPath(results, expectedPath), + "Results should contain path with: %s\nGot: %v", expectedPath, results) + } + + // Assert does not contain excluded paths + for _, excludedPath := range tt.shouldNotContain { + assert.False(t, containsPath(results, excludedPath), + "Results should NOT contain path with: %s\nGot: %v", excludedPath, results) + } + }) + } +} + +// Helper to check if results contain a path ending with the given suffix +func containsPath(results []string, pathSuffix string) bool { + for _, r := range results { + // Normalize both paths and check if result ends with the suffix + cleanResult := filepath.Clean(r) + cleanSuffix := filepath.Clean(pathSuffix) + + // Check if it's a suffix match (e.g., "src/package.json" matches ".../src/package.json") + if strings.HasSuffix(cleanResult, string(filepath.Separator)+cleanSuffix) || + strings.HasSuffix(cleanResult, cleanSuffix) { + return true + } + + // Also check if pathSuffix is just a directory name component (for shouldNotContain checks) + if !strings.Contains(pathSuffix, string(filepath.Separator)) { + parts := strings.Split(cleanResult, string(filepath.Separator)) + for _, part := range parts { + if part == pathSuffix { + return true + } + } + } + } + return false +} diff --git a/pkg/project/project.go b/pkg/project/project.go index 218705c773..acb4e7db1d 100644 --- a/pkg/project/project.go +++ b/pkg/project/project.go @@ -35,6 +35,7 @@ type App struct { Home string `json:"home"` Version string `json:"version"` Protect bool `json:"protect"` + Exclude []string `json:"exclude"` // Deprecated: Backend is now Home Backend string `json:"backend"` // Deprecated: RemovalPolicy is now Removal diff --git a/pkg/project/run.go b/pkg/project/run.go index 58f277c966..89edf050b2 100644 --- a/pkg/project/run.go +++ b/pkg/project/run.go @@ -527,7 +527,7 @@ loop: complete.Finished = finished complete.Errors = errors complete.ImportDiffs = importDiffs - types.Generate(p.PathConfig(), complete.Links) + types.Generate(p.PathConfig(), complete.Links, p.app.Exclude) defer bus.Publish(complete) if input.Command != "diff" { diff --git a/pkg/types/python/python.go b/pkg/types/python/python.go index 1275eec72d..193cf1e9b7 100644 --- a/pkg/types/python/python.go +++ b/pkg/types/python/python.go @@ -11,8 +11,8 @@ import ( "github.com/sst/sst/v3/pkg/project/common" ) -func Generate(root string, links common.Links) error { - projects := fs.FindDown(root, "pyproject.toml") +func Generate(root string, links common.Links, exclude []string) error { + projects := fs.FindDownWithExcludes(root, "pyproject.toml", exclude) files := []io.Writer{} for _, project := range projects { path := filepath.Join(filepath.Dir(project), "sst.pyi") diff --git a/pkg/types/rails/rails.go b/pkg/types/rails/rails.go index 00bbabd314..1933e3c1a2 100644 --- a/pkg/types/rails/rails.go +++ b/pkg/types/rails/rails.go @@ -10,9 +10,9 @@ import ( "github.com/sst/sst/v3/pkg/project/common" ) -func Generate(root string, links common.Links) error { +func Generate(root string, links common.Links, exclude []string) error { return nil - projects := fs.FindDown(root, "config.ru") + projects := fs.FindDownWithExcludes(root, "config.ru", exclude) files := []io.Writer{} for _, project := range projects { // check if lib path exists diff --git a/pkg/types/types.go b/pkg/types/types.go index 36fd70cff6..10461df74a 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -10,17 +10,17 @@ import ( "github.com/sst/sst/v3/pkg/types/typescript" ) -type Generator = func(root string, complete common.Links) error +type Generator = func(root string, complete common.Links, exclude []string) error -func Generate(cfgPath string, complete common.Links) error { +func Generate(cfgPath string, complete common.Links, exclude []string) error { root := path.ResolveRootDir(cfgPath) // gitroot, err := fs.FindUp(root, ".git") // if err == nil { // root = filepath.Dir(gitroot) // } - slog.Info("generating types", "root", root) + slog.Info("generating types", "root", root, "exclude", exclude) for _, generator := range All { - err := generator(root, complete) + err := generator(root, complete, exclude) if err != nil { return err } diff --git a/pkg/types/typescript/typescript.go b/pkg/types/typescript/typescript.go index fb6abaf1dd..2d13d2c422 100644 --- a/pkg/types/typescript/typescript.go +++ b/pkg/types/typescript/typescript.go @@ -20,7 +20,7 @@ var mapping = map[string]string{ "serviceBindings": "Service", } -func Generate(root string, links common.Links) error { +func Generate(root string, links common.Links, exclude []string) error { cloudflareBindings := map[string]string{} for name, link := range links { for _, include := range link.Include { @@ -46,7 +46,7 @@ func Generate(root string, links common.Links) error { "export {}", }, "\n")) - packageJsons := fs.FindDown(root, "package.json") + packageJsons := fs.FindDownWithExcludes(root, "package.json", exclude) rootEnv := filepath.Join(root, "sst-env.d.ts") for _, packageJson := range packageJsons { packageJsonFile, err := os.Open(packageJson)