From 396abe91840a2729a5a74210765cfa67bcde0ce9 Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Fri, 5 Dec 2025 23:50:25 +0000 Subject: [PATCH 1/5] fix: windows --- integration/multi_test.go | 4 - integration/patches_git_test.go | 17 ++- integration/workflow_registry_test.go | 21 +-- internal/fs/fs.go | 2 + internal/git/plumbing.go | 197 +++++++++++++++++++++++++- internal/patches/git_adapter.go | 3 +- internal/patches/scanner.go | 3 +- 7 files changed, 229 insertions(+), 18 deletions(-) diff --git a/integration/multi_test.go b/integration/multi_test.go index 94c281696..ce0b55a7a 100644 --- a/integration/multi_test.go +++ b/integration/multi_test.go @@ -3,7 +3,6 @@ package integration_tests import ( "os" "path/filepath" - "runtime" "testing" "github.com/google/go-cmp/cmp" @@ -13,9 +12,6 @@ import ( ) func TestMultiFileStability(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("Skipping test on Windows") - } // If windows, skip temp := setupTestDir(t) diff --git a/integration/patches_git_test.go b/integration/patches_git_test.go index 812dc3d88..5eff107af 100644 --- a/integration/patches_git_test.go +++ b/integration/patches_git_test.go @@ -723,16 +723,27 @@ func TestGitArchitecture_ImplicitFetchFromRemote(t *testing.T) { require.NoError(t, err, "git init --bare failed: %s", string(output)) t.Logf("Created bare remote at %s", remoteDir) + // Configure bare remote to not convert line endings + for _, args := range [][]string{ + {"config", "core.autocrlf", "false"}, + } { + cmd = exec.Command("git", args...) + cmd.Dir = remoteDir + cmd.CombinedOutput() + } + // Step 2: Clone to Environment A and set up for generation cmd = exec.Command("git", "clone", remoteDir, ".") cmd.Dir = envADir output, err = cmd.CombinedOutput() require.NoError(t, err, "git clone to envA failed: %s", string(output)) - // Configure git user in envA + // Configure git user in envA and disable line ending conversion for consistency for _, args := range [][]string{ {"config", "user.email", "test@example.com"}, {"config", "user.name", "Test User"}, + {"config", "core.autocrlf", "false"}, + {"config", "core.eol", "lf"}, } { cmd = exec.Command("git", args...) cmd.Dir = envADir @@ -777,10 +788,12 @@ func TestGitArchitecture_ImplicitFetchFromRemote(t *testing.T) { output, err = cmd.CombinedOutput() require.NoError(t, err, "git clone to envB failed: %s", string(output)) - // Configure git user in envB + // Configure git user in envB and disable line ending conversion for consistency for _, args := range [][]string{ {"config", "user.email", "devb@example.com"}, {"config", "user.name", "Developer B"}, + {"config", "core.autocrlf", "false"}, + {"config", "core.eol", "lf"}, } { cmd = exec.Command("git", args...) cmd.Dir = envBDir diff --git a/integration/workflow_registry_test.go b/integration/workflow_registry_test.go index 70a803a48..6297b2606 100644 --- a/integration/workflow_registry_test.go +++ b/integration/workflow_registry_test.go @@ -54,10 +54,9 @@ typescript: gitInit(t, temp) // Run the initial generation - // Note: Using executeI (inline) to capture debug output var initialChecksums map[string]string initialArgs := []string{"run", "-t", "all", "--force", "--pinned", "--skip-versioning", "--skip-compile"} - cmdErr := executeI(t, temp, initialArgs...).Run() + cmdErr := execute(t, temp, initialArgs...).Run() require.NoError(t, cmdErr) // Calculate checksums of generated files @@ -70,15 +69,20 @@ typescript: rerunChecksums, err := filesToString(temp) require.NoError(t, err) - // Find differences to help debug + // Find differences to help debug test failures + tempDir := os.TempDir() for key, val := range initialChecksums { if rerunVal, ok := rerunChecksums[key]; ok { if val != rerunVal { t.Logf("File differs: %s", key) // Save files for comparison - _ = os.WriteFile("/tmp/initial_"+filepath.Base(key), []byte(val), 0644) - _ = os.WriteFile("/tmp/rerun_"+filepath.Base(key), []byte(rerunVal), 0644) - t.Logf("Saved to /tmp/initial_%s and /tmp/rerun_%s", filepath.Base(key), filepath.Base(key)) + initialPath := filepath.Join(tempDir, "initial_"+filepath.Base(key)) + rerunPath := filepath.Join(tempDir, "rerun_"+filepath.Base(key)) + _ = os.WriteFile(initialPath, []byte(val), 0644) + _ = os.WriteFile(rerunPath, []byte(rerunVal), 0644) + t.Logf("Saved to %s and %s", initialPath, rerunPath) + t.Logf("Initial (first 200): %s", truncate(val, 200)) + t.Logf("Rerun (first 200): %s", truncate(rerunVal, 200)) } } else { t.Logf("File missing in rerun: %s", key) @@ -106,10 +110,9 @@ typescript: require.NoError(t, err) // exclude gen.lock -- we could (we do) reformat the document inside the frozen one + // Now that filesToString normalizes to forward slashes, we only need one delete delete(frozenChecksums, ".speakeasy/gen.lock") - delete(frozenChecksums, ".speakeasy\\gen.lock") // windows delete(initialChecksums, ".speakeasy/gen.lock") - delete(initialChecksums, ".speakeasy\\gen.lock") // windows // Compare checksums require.Equal(t, initialChecksums, frozenChecksums, "Generated files should be identical when using --frozen-workflow-lock") } @@ -231,6 +234,8 @@ func filesToString(dir string) (map[string]string, error) { return err } relPath, _ := filepath.Rel(dir, path) + // Normalize path separators to forward slashes for cross-platform consistency + relPath = filepath.ToSlash(relPath) checksums[relPath] = string(data) } return nil diff --git a/internal/fs/fs.go b/internal/fs/fs.go index 4783bba49..e9478b05b 100644 --- a/internal/fs/fs.go +++ b/internal/fs/fs.go @@ -94,6 +94,8 @@ func (f *FileSystem) ScanForGeneratedIDs() (map[string]string, error) { if err != nil { return nil } + // Normalize to forward slashes for cross-platform consistency + relPath = filepath.ToSlash(relPath) // Try to extract ID from file header id, err := extractGeneratedIDFromFile(path) diff --git a/internal/git/plumbing.go b/internal/git/plumbing.go index 26854be35..dc73a14e3 100644 --- a/internal/git/plumbing.go +++ b/internal/git/plumbing.go @@ -3,7 +3,9 @@ package git import ( "bytes" "fmt" + "os/exec" "path/filepath" + "runtime" "sort" "strings" "time" @@ -16,6 +18,33 @@ import ( // WriteBlob writes content to the git object database and returns the SHA-1 hash. func (r *Repository) WriteBlob(content []byte) (string, error) { + // On Windows, use native git commands due to go-git file locking issues + // that cause "Access is denied" errors when writing/renaming blob objects + if runtime.GOOS == "windows" { + return r.writeBlobNative(content) + } + return r.writeBlobGoGit(content) +} + +// writeBlobNative writes a blob using native git commands (for Windows compatibility). +func (r *Repository) writeBlobNative(content []byte) (string, error) { + repoRoot := r.Root() + if repoRoot == "" { + return "", fmt.Errorf("repository root not found") + } + + cmd := exec.Command("git", "hash-object", "-w", "--stdin") + cmd.Dir = repoRoot + cmd.Stdin = bytes.NewReader(content) + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("git hash-object failed: %w", err) + } + return strings.TrimSpace(string(output)), nil +} + +// writeBlobGoGit writes a blob using go-git (for non-Windows platforms). +func (r *Repository) writeBlobGoGit(content []byte) (string, error) { obj := r.repo.Storer.NewEncodedObject() obj.SetType(plumbing.BlobObject) obj.SetSize(int64(len(content))) @@ -74,6 +103,43 @@ type TreeEntry struct { // WriteTree creates a tree object from the provided entries and returns its hash. // Note: This creates a single tree object (flat). For deep trees, hashes must be pre-calculated. func (r *Repository) WriteTree(entries []TreeEntry) (string, error) { + // On Windows, use native git commands due to go-git file locking issues + if runtime.GOOS == "windows" { + return r.writeTreeNative(entries) + } + return r.writeTreeGoGit(entries) +} + +// writeTreeNative creates a tree object using native git commands (for Windows compatibility). +func (r *Repository) writeTreeNative(entries []TreeEntry) (string, error) { + repoRoot := r.Root() + if repoRoot == "" { + return "", fmt.Errorf("repository root not found") + } + + // Build tree input for git mktree + // Format: SP SP TAB LF + var treeInput strings.Builder + for _, e := range entries { + objType := "blob" + if e.Mode == "040000" { + objType = "tree" + } + fmt.Fprintf(&treeInput, "%s %s %s\t%s\n", e.Mode, objType, e.Hash, e.Name) + } + + cmd := exec.Command("git", "mktree") + cmd.Dir = repoRoot + cmd.Stdin = strings.NewReader(treeInput.String()) + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("git mktree failed: %w", err) + } + return strings.TrimSpace(string(output)), nil +} + +// writeTreeGoGit creates a tree object using go-git (for non-Windows platforms). +func (r *Repository) writeTreeGoGit(entries []TreeEntry) (string, error) { var treeEntries []object.TreeEntry for _, e := range entries { @@ -121,6 +187,44 @@ func (r *Repository) WriteTree(entries []TreeEntry) (string, error) { // CommitTree creates a commit object pointing to a tree and parent(s). func (r *Repository) CommitTree(treeHash, parent, message string) (string, error) { + // On Windows, use native git commands due to go-git file locking issues + if runtime.GOOS == "windows" { + return r.commitTreeNative(treeHash, parent, message) + } + return r.commitTreeGoGit(treeHash, parent, message) +} + +// commitTreeNative creates a commit object using native git commands (for Windows compatibility). +func (r *Repository) commitTreeNative(treeHash, parent, message string) (string, error) { + repoRoot := r.Root() + if repoRoot == "" { + return "", fmt.Errorf("repository root not found") + } + + args := []string{"commit-tree", treeHash, "-m", message} + if parent != "" { + args = append(args, "-p", parent) + } + + cmd := exec.Command("git", args...) + cmd.Dir = repoRoot + // Set environment for consistent commit identity + cmd.Env = append(cmd.Environ(), + "GIT_AUTHOR_NAME=Speakeasy Bot", + "GIT_AUTHOR_EMAIL=bot@speakeasyapi.dev", + "GIT_COMMITTER_NAME=Speakeasy Bot", + "GIT_COMMITTER_EMAIL=bot@speakeasyapi.dev", + ) + + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("git commit-tree failed: %w", err) + } + return strings.TrimSpace(string(output)), nil +} + +// commitTreeGoGit creates a commit object using go-git (for non-Windows platforms). +func (r *Repository) commitTreeGoGit(treeHash, parent, message string) (string, error) { tHash := plumbing.NewHash(treeHash) var parents []plumbing.Hash @@ -220,8 +324,99 @@ func (r *Repository) SetConflictState(path string, base, ours, theirs []byte, is } // Git index always uses forward slashes, even on Windows - path = strings.ReplaceAll(path, "\\", "/") + path = filepath.ToSlash(path) + + // On Windows, use native git commands due to go-git file locking issues + // that cause "Access is denied" errors when writing blobs + if runtime.GOOS == "windows" { + return r.setConflictStateNative(path, base, ours, theirs, isExecutable) + } + + return r.setConflictStateGoGit(path, base, ours, theirs, isExecutable) +} + +// setConflictStateNative uses native git commands to set up conflict state. +// This is used on Windows where go-git has file locking issues. +func (r *Repository) setConflictStateNative(path string, base, ours, theirs []byte, isExecutable bool) error { + repoRoot := r.Root() + if repoRoot == "" { + return fmt.Errorf("repository root not found") + } + + // Determine file mode for git + modeStr := "100644" + if isExecutable { + modeStr = "100755" + } + + // Helper to write blob using native git + writeBlob := func(content []byte) (string, error) { + cmd := exec.Command("git", "hash-object", "-w", "--stdin") + cmd.Dir = repoRoot + cmd.Stdin = bytes.NewReader(content) + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("git hash-object failed: %w", err) + } + return strings.TrimSpace(string(output)), nil + } + + // Write the blob objects + var baseHash string + var err error + if base != nil { + baseHash, err = writeBlob(base) + if err != nil { + return fmt.Errorf("failed to write base blob: %w", err) + } + } + + oursHash, err := writeBlob(ours) + if err != nil { + return fmt.Errorf("failed to write ours blob: %w", err) + } + + theirsHash, err := writeBlob(theirs) + if err != nil { + return fmt.Errorf("failed to write theirs blob: %w", err) + } + + // First, remove any existing entry for this path from the index + cmd := exec.Command("git", "update-index", "--force-remove", "--", path) + cmd.Dir = repoRoot + // Ignore error - file might not be in index + _ = cmd.Run() + + // Build index entries for conflict stages + // Format for --index-info: SP SP TAB + var indexInfo strings.Builder + + // Stage 1: Base (ancestor) - only if base exists + if base != nil && baseHash != "" { + fmt.Fprintf(&indexInfo, "%s %s 1\t%s\n", modeStr, baseHash, path) + } + + // Stage 2: Ours (current) + fmt.Fprintf(&indexInfo, "%s %s 2\t%s\n", modeStr, oursHash, path) + + // Stage 3: Theirs (incoming) + fmt.Fprintf(&indexInfo, "%s %s 3\t%s\n", modeStr, theirsHash, path) + + // Update the index with conflict entries + cmd = exec.Command("git", "update-index", "--index-info") + cmd.Dir = repoRoot + cmd.Stdin = strings.NewReader(indexInfo.String()) + + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to update index: %w: %s", err, string(output)) + } + + return nil +} +// setConflictStateGoGit uses go-git to set up conflict state. +// This is preferred on non-Windows platforms. +func (r *Repository) setConflictStateGoGit(path string, base, ours, theirs []byte, isExecutable bool) error { // Read the current index idx, err := r.repo.Storer.Index() if err != nil { diff --git a/internal/patches/git_adapter.go b/internal/patches/git_adapter.go index 73496f3f6..f1e5fd407 100644 --- a/internal/patches/git_adapter.go +++ b/internal/patches/git_adapter.go @@ -3,6 +3,7 @@ package patches import ( "fmt" "path" + "path/filepath" "sort" "strings" @@ -86,7 +87,7 @@ func NewGitAdapter(repo GitRepository, baseDir string) *GitAdapter { // toGitPath converts OS-specific path separators to forward slashes for git. func toGitPath(p string) string { - return strings.ReplaceAll(p, "\\", "/") + return filepath.ToSlash(p) } // prependBaseDir adds the baseDir prefix to a generation-relative path. diff --git a/internal/patches/scanner.go b/internal/patches/scanner.go index 1b9ca8610..2ee50ebe4 100644 --- a/internal/patches/scanner.go +++ b/internal/patches/scanner.go @@ -6,7 +6,6 @@ import ( "os" "path/filepath" "regexp" - "strings" "github.com/speakeasy-api/openapi-generation/v2/pkg/generate" ) @@ -71,7 +70,7 @@ func (s *Scanner) Scan() (*ScanResult, error) { } // Normalize to forward slashes (git/lockfile convention) - relPath = strings.ReplaceAll(relPath, "\\", "/") + relPath = filepath.ToSlash(relPath) // Try to extract ID from file header (supports both UUID and short ID formats) id, err := extractGeneratedIDFromFile(path) From 1094db590dcc7fedfe73e748efbdd2ba27fd868e Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Fri, 5 Dec 2025 23:55:06 +0000 Subject: [PATCH 2/5] fix: cleanup --- integration/patches_git_test.go | 20 ++++++++++++-------- integration/workflow_registry_test.go | 1 - 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/integration/patches_git_test.go b/integration/patches_git_test.go index 5eff107af..dc6a24ba6 100644 --- a/integration/patches_git_test.go +++ b/integration/patches_git_test.go @@ -810,19 +810,23 @@ func TestGitArchitecture_ImplicitFetchFromRemote(t *testing.T) { } t.Log("Verified: speakeasy refs not present in fresh clone (as expected)") - // Step 5: Modify a generated file in Environment B - sdkFile := filepath.Join(envBDir, "sdk.go") - require.FileExists(t, sdkFile, "sdk.go should exist in envB") + // Step 5: Modify a generated model file in Environment B + // We use a model file instead of sdk.go because version bumps modify sdk.go's + // version constants and can cause conflicts with edits near the package declaration. + petFile := filepath.Join(envBDir, "models", "components", "pet.go") + require.FileExists(t, petFile, "pet.go should exist in envB") - content, err := os.ReadFile(sdkFile) + content, err := os.ReadFile(petFile) require.NoError(t, err) originalID := extractGeneratedIDFromContent(content) - require.NotEmpty(t, originalID, "sdk.go should have @generated-id") + require.NotEmpty(t, originalID, "pet.go should have @generated-id") + // Add a custom method at the end of the file (after the struct closing brace) + // This is less likely to conflict with generator changes modifiedContent := strings.Replace(string(content), - "package testsdk", - "package testsdk\n\n// ENVB_USER_EDIT: Developer B's customization", 1) - err = os.WriteFile(sdkFile, []byte(modifiedContent), 0644) + "}\n", + "}\n\n// ENVB_USER_EDIT: Developer B's customization\nfunc (p *Pet) CustomMethod() string {\n\treturn \"custom\"\n}\n", 1) + err = os.WriteFile(petFile, []byte(modifiedContent), 0644) require.NoError(t, err) gitCommitAllInDir(t, envBDir, "developer B user edit") diff --git a/integration/workflow_registry_test.go b/integration/workflow_registry_test.go index 6297b2606..eff3c6f6b 100644 --- a/integration/workflow_registry_test.go +++ b/integration/workflow_registry_test.go @@ -110,7 +110,6 @@ typescript: require.NoError(t, err) // exclude gen.lock -- we could (we do) reformat the document inside the frozen one - // Now that filesToString normalizes to forward slashes, we only need one delete delete(frozenChecksums, ".speakeasy/gen.lock") delete(initialChecksums, ".speakeasy/gen.lock") // Compare checksums From e6ee45316f6054ed32f2e5ae5ae86fe94f0e5273 Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Sat, 6 Dec 2025 00:13:57 +0000 Subject: [PATCH 3/5] chore: windows compat --- integration/patches_git_test.go | 12 ++++++------ internal/git/plumbing.go | 25 ++++++++++++------------- internal/git/repository.go | 16 ++++++++++++---- 3 files changed, 30 insertions(+), 23 deletions(-) diff --git a/integration/patches_git_test.go b/integration/patches_git_test.go index dc6a24ba6..eca5aaeb7 100644 --- a/integration/patches_git_test.go +++ b/integration/patches_git_test.go @@ -821,11 +821,9 @@ func TestGitArchitecture_ImplicitFetchFromRemote(t *testing.T) { originalID := extractGeneratedIDFromContent(content) require.NotEmpty(t, originalID, "pet.go should have @generated-id") - // Add a custom method at the end of the file (after the struct closing brace) - // This is less likely to conflict with generator changes - modifiedContent := strings.Replace(string(content), - "}\n", - "}\n\n// ENVB_USER_EDIT: Developer B's customization\nfunc (p *Pet) CustomMethod() string {\n\treturn \"custom\"\n}\n", 1) + // Add a custom method at the end of the file + // We append to the very end of the file to avoid conflict with generated getters + modifiedContent := string(content) + "\n// ENVB_USER_EDIT: Developer B's customization\nfunc (p *Pet) CustomMethod() string {\n\treturn \"custom\"\n}\n" err = os.WriteFile(petFile, []byte(modifiedContent), 0644) require.NoError(t, err) gitCommitAllInDir(t, envBDir, "developer B user edit") @@ -837,10 +835,12 @@ func TestGitArchitecture_ImplicitFetchFromRemote(t *testing.T) { gitCommitAllInDir(t, envBDir, "generation 2 in envB") // Step 7: Verify user edit was preserved (proving 3-way merge worked) - finalContent, err := os.ReadFile(sdkFile) + finalContent, err := os.ReadFile(petFile) require.NoError(t, err) require.Contains(t, string(finalContent), "ENVB_USER_EDIT: Developer B's customization", "User modification should be preserved after generation (3-way merge worked)") + require.Contains(t, string(finalContent), "func (p *Pet) CustomMethod()", + "Custom method should be preserved after generation") // Verify ID is preserved finalID := extractGeneratedIDFromContent(finalContent) diff --git a/internal/git/plumbing.go b/internal/git/plumbing.go index dc73a14e3..5293f57fa 100644 --- a/internal/git/plumbing.go +++ b/internal/git/plumbing.go @@ -69,28 +69,27 @@ func (r *Repository) writeBlobGoGit(content []byte) (string, error) { } // GetBlob retrieves the content of a blob by its SHA-1 hash. +// Uses native git commands to ensure we can read objects fetched by native git fetch. func (r *Repository) GetBlob(hash string) ([]byte, error) { // Strip "sha1:" prefix if present (common in some systems) hash = strings.TrimPrefix(hash, "sha1:") - h := plumbing.NewHash(hash) - blob, err := r.repo.BlobObject(h) - if err != nil { - return nil, fmt.Errorf("failed to find blob %s: %w", hash, err) + repoRoot := r.Root() + if repoRoot == "" { + return nil, fmt.Errorf("repository root not found") } - reader, err := blob.Reader() + // Use native git to read blob content. + // This is necessary because go-git's storer may not see objects + // that were fetched via native git fetch commands. + cmd := exec.Command("git", "cat-file", "blob", hash) + cmd.Dir = repoRoot + output, err := cmd.Output() if err != nil { - return nil, fmt.Errorf("failed to open blob reader: %w", err) - } - defer reader.Close() - - buf := new(bytes.Buffer) - if _, err := buf.ReadFrom(reader); err != nil { - return nil, fmt.Errorf("failed to read blob content: %w", err) + return nil, fmt.Errorf("failed to read blob %s: %w", hash, err) } - return buf.Bytes(), nil + return output, nil } // TreeEntry represents a file or directory in a git tree. diff --git a/internal/git/repository.go b/internal/git/repository.go index 6ed1a24a5..0afaf9daa 100644 --- a/internal/git/repository.go +++ b/internal/git/repository.go @@ -74,6 +74,7 @@ func (r *Repository) Root() string { } // HasObject checks if a blob or commit exists in the local object database. +// Uses native git commands to ensure we see objects fetched by native git fetch. func (r *Repository) HasObject(hash string) bool { if r.repo == nil { return false @@ -81,11 +82,18 @@ func (r *Repository) HasObject(hash string) bool { // Strip "sha1:" prefix if present hash = strings.TrimPrefix(hash, "sha1:") - h := plumbing.NewHash(hash) - // Try to get the object - if it exists, return true - _, err := r.repo.Storer.EncodedObject(plumbing.AnyObject, h) - return err == nil + // Use native git to check object existence. + // This is necessary because go-git's storer may not see objects + // that were fetched via native git fetch commands. + repoRoot := r.Root() + if repoRoot == "" { + return false + } + + cmd := exec.Command("git", "cat-file", "-e", hash) + cmd.Dir = repoRoot + return cmd.Run() == nil } // FetchRef fetches a specific ref from origin. From 63ca27b33f8453c9dfc01059c4f3df9a019ece6f Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Tue, 9 Dec 2025 11:46:41 +0000 Subject: [PATCH 4/5] chore: new test --- integration/file_deletion_test.go | 240 ++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 integration/file_deletion_test.go diff --git a/integration/file_deletion_test.go b/integration/file_deletion_test.go new file mode 100644 index 000000000..e93a23caf --- /dev/null +++ b/integration/file_deletion_test.go @@ -0,0 +1,240 @@ +package integration_tests + +import ( + "os" + "path/filepath" + "testing" + + "github.com/speakeasy-api/sdk-gen-config/workflow" + "github.com/stretchr/testify/require" +) + +// TestFileDeletion_UnusedModelRemoved verifies that when a model is no longer used in the OpenAPI spec, +// the generated model file is deleted on regeneration. +func TestFileDeletion_UnusedModelRemoved(t *testing.T) { + t.Parallel() + temp := setupFileDeletionTestDir(t) + + // Initial generation with both Pet and Owner models + err := execute(t, temp, "run", "-t", "all", "--pinned", "--skip-compile", "--output", "console").Run() + require.NoError(t, err, "Initial generation should succeed") + + // Commit initial generation + gitCommitAll(t, temp, "initial generation") + + // Verify both model files exist + petFile := filepath.Join(temp, "models", "components", "pet.go") + ownerFile := filepath.Join(temp, "models", "components", "owner.go") + + require.FileExists(t, petFile, "Pet model file should exist after initial generation") + require.FileExists(t, ownerFile, "Owner model file should exist after initial generation") + + t.Logf("Initial generation complete. Pet file: %s, Owner file: %s", petFile, ownerFile) + + // List all generated files for debugging + t.Log("Generated files after initial generation:") + listGeneratedFiles(t, temp) + + // Dump gen.lock after first generation + genLockPath := filepath.Join(temp, ".speakeasy", "gen.lock") + if content, err := os.ReadFile(genLockPath); err == nil { + t.Logf("gen.lock after initial generation:\n%s", string(content)) + } else { + t.Logf("Could not read gen.lock: %v", err) + } + + // Now update the spec to remove the Owner model usage + specWithoutOwner := `openapi: 3.0.3 +info: + title: Test API + version: 1.0.0 +servers: + - url: https://api.example.com +paths: + /pets: + get: + summary: List pets + operationId: listPets + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' +components: + schemas: + Pet: + type: object + properties: + id: + type: integer + name: + type: string +` + err = os.WriteFile(filepath.Join(temp, "spec.yaml"), []byte(specWithoutOwner), 0644) + require.NoError(t, err) + gitCommitAll(t, temp, "spec update: remove Owner model") + + // Regenerate + t.Log("Regenerating without Owner model...") + err = execute(t, temp, "run", "-t", "all", "--pinned", "--skip-compile", "--output", "console").Run() + require.NoError(t, err, "Regeneration should succeed") + + // List generated files after regeneration for debugging + t.Log("Generated files after regeneration:") + listGeneratedFiles(t, temp) + + // Dump gen.lock after regeneration + if content, err := os.ReadFile(genLockPath); err == nil { + t.Logf("gen.lock after regeneration:\n%s", string(content)) + } else { + t.Logf("Could not read gen.lock: %v", err) + } + + // Pet model should still exist + require.FileExists(t, petFile, "Pet model file should still exist after regeneration") + + // Owner model should be DELETED since it's no longer used + _, err = os.Stat(ownerFile) + require.True(t, os.IsNotExist(err), "Owner model file should be deleted when no longer used in spec. Error: %v", err) +} + +// setupFileDeletionTestDir creates a test directory with an OpenAPI spec that uses two models +func setupFileDeletionTestDir(t *testing.T) string { + t.Helper() + + temp := setupTestDir(t) + + // Create an OpenAPI spec with TWO models: Pet and Owner + // Both are used by the /pets endpoint + specContent := `openapi: 3.0.3 +info: + title: Test API + version: 1.0.0 +servers: + - url: https://api.example.com +paths: + /pets: + get: + summary: List pets + operationId: listPets + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + /owners: + get: + summary: List owners + operationId: listOwners + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Owner' +components: + schemas: + Pet: + type: object + properties: + id: + type: integer + name: + type: string + Owner: + type: object + properties: + id: + type: integer + name: + type: string + email: + type: string +` + err := os.WriteFile(filepath.Join(temp, "spec.yaml"), []byte(specContent), 0644) + require.NoError(t, err) + + // Create .speakeasy directory + err = os.MkdirAll(filepath.Join(temp, ".speakeasy"), 0755) + require.NoError(t, err) + + // Create workflow.yaml + workflowFile := &workflow.Workflow{ + Version: workflow.WorkflowVersion, + Sources: map[string]workflow.Source{ + "test-source": { + Inputs: []workflow.Document{ + {Location: "spec.yaml"}, + }, + }, + }, + Targets: map[string]workflow.Target{ + "test-target": { + Target: "go", + Source: "test-source", + }, + }, + } + err = workflow.Save(temp, workflowFile) + require.NoError(t, err) + + // Create gen.yaml + genYamlContent := `configVersion: 2.0.0 +generation: + sdkClassName: SDK + maintainOpenAPIOrder: true + usageSnippets: + optionalPropertyRendering: withExample +go: + version: 1.0.0 + packageName: testsdk +` + err = os.WriteFile(filepath.Join(temp, "gen.yaml"), []byte(genYamlContent), 0644) + require.NoError(t, err) + + // Create .genignore to exclude go.mod/go.sum from generation + genignoreContent := `go.mod +go.sum +` + err = os.WriteFile(filepath.Join(temp, ".genignore"), []byte(genignoreContent), 0644) + require.NoError(t, err) + + // Initialize git repo + gitInit(t, temp) + gitCommitAll(t, temp, "initial commit") + + return temp +} + +// listGeneratedFiles lists all generated files for debugging purposes +func listGeneratedFiles(t *testing.T, dir string) { + t.Helper() + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + // Skip hidden directories + if info.IsDir() && info.Name()[0] == '.' { + return filepath.SkipDir + } + if !info.IsDir() { + relPath, _ := filepath.Rel(dir, path) + t.Logf(" %s", relPath) + } + return nil + }) + if err != nil { + t.Logf("Error walking directory: %v", err) + } +} From edd3d75170da61d96a7da378f68fd4272347e29f Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Tue, 9 Dec 2025 12:45:43 +0000 Subject: [PATCH 5/5] chore: remove replace directive --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index b9ae93f8b..c9ca3a6c0 100644 --- a/go.mod +++ b/go.mod @@ -38,7 +38,7 @@ require ( github.com/speakeasy-api/huh v1.1.2 github.com/speakeasy-api/jq v0.1.1-0.20251107233444-84d7e49e84a4 github.com/speakeasy-api/openapi v1.11.3 - github.com/speakeasy-api/openapi-generation/v2 v2.776.1 + github.com/speakeasy-api/openapi-generation/v2 v2.776.3 github.com/speakeasy-api/openapi-overlay v0.10.3 github.com/speakeasy-api/sdk-gen-config v1.43.1 github.com/speakeasy-api/speakeasy-client-sdk-go/v3 v3.26.7 @@ -156,7 +156,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect - github.com/mailru/easyjson v0.9.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect diff --git a/go.sum b/go.sum index c54528100..d67d4f290 100644 --- a/go.sum +++ b/go.sum @@ -357,8 +357,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= -github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= @@ -535,8 +535,8 @@ github.com/speakeasy-api/libopenapi v0.21.9-fixhiddencomps-fixed h1:PL/kpBY5vkBm github.com/speakeasy-api/libopenapi v0.21.9-fixhiddencomps-fixed/go.mod h1:Gc8oQkjr2InxwumK0zOBtKN9gIlv9L2VmSVIUk2YxcU= github.com/speakeasy-api/openapi v1.11.3 h1:7ExJYuJM3hiV35EROK3i+/w8SdEep3Ct9BivGaOkbNI= github.com/speakeasy-api/openapi v1.11.3/go.mod h1:ITV3em4IFe1Hd4gX5Peq9TE7+Rfd/WIHZE/aqxNgihg= -github.com/speakeasy-api/openapi-generation/v2 v2.776.1 h1:3BUJ5aLRNBKut6EwgsOirnOC8AJKQ1mBu0dfH2Zlnqo= -github.com/speakeasy-api/openapi-generation/v2 v2.776.1/go.mod h1:3D4+XhJtPioeRySRO3CLJXo5fuupWNZYHvw8iYioYBc= +github.com/speakeasy-api/openapi-generation/v2 v2.776.3 h1:gomKKQd4vKthVl3T7Ydq+m/LH6BzygeAq4eZpOmRXOU= +github.com/speakeasy-api/openapi-generation/v2 v2.776.3/go.mod h1:3D4+XhJtPioeRySRO3CLJXo5fuupWNZYHvw8iYioYBc= github.com/speakeasy-api/openapi-overlay v0.10.3 h1:70een4vwHyslIp796vM+ox6VISClhtXsCjrQNhxwvWs= github.com/speakeasy-api/openapi-overlay v0.10.3/go.mod h1:RJjV0jbUHqXLS0/Mxv5XE7LAnJHqHw+01RDdpoGqiyY= github.com/speakeasy-api/sdk-gen-config v1.43.1 h1:rhhv6mAVV2yl1I6TqHkpunnOnSXyH5milgzEA9vJPEo=