Skip to content

refactor(platformifier): use fs.FS and return file map#261

Open
pjcdawkins wants to merge 2 commits intorefactor/abstract-fsfrom
refactor/abstract-fs-2-fsys
Open

refactor(platformifier): use fs.FS and return file map#261
pjcdawkins wants to merge 2 commits intorefactor/abstract-fsfrom
refactor/abstract-fs-2-fsys

Conversation

@pjcdawkins
Copy link
Contributor

Summary

Stacked on #260.

Abstract the filesystem using Go's standard fs.FS interface and change Platformify() to return map[string][]byte instead of writing files to disk.

  • utils: all file functions (FileExists, FindFile, FindAllFiles, GetJSONValue, GetTOMLValue) take fs.FS as first parameter
  • models: WorkingDirectory becomes fs.FS, new Cwd field stores the OS path
  • platformifier: interface returns (map[string][]byte, error), implementations collect files in map
  • commands: Platformify() writes the returned file map to disk
  • Remove custom FS interface, mocks, and nextjs platformifier
  • Adapt all platformifier tests to use fstest.MapFS

This enables callers like source-integration-apps to operate on in-memory filesystems and handle file output themselves.

Author: @akalipetis

🤖 Generated with Claude Code

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Refactors the platformifier pipeline to abstract filesystem access via Go’s fs.FS and to have Platformify() return a map[string][]byte of generated files, moving disk writes into the CLI command layer. This enables callers to operate on in-memory filesystems and manage output themselves.

Changes:

  • Update shared file utilities to accept fs.FS and migrate call sites to use os.DirFS/fstest.MapFS.
  • Change platformifier implementations to return a generated file map instead of writing to disk; remove custom FS interface and gomock-based mocks.
  • Update CLI commands.Platformify() to persist returned files to disk and adapt tests accordingly.

Reviewed changes

Copilot reviewed 28 out of 28 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
validator/validator.go Uses os.DirFS + updated utils to locate .platform.app.yaml files.
platformifier/platformifier_test.go Updates tests for new (map[string][]byte, error) API; replaces gomock suite with simple test doubles; writes returned files to temp dirs for validation.
platformifier/platformifier_mock_test.go Removes generated gomock for the old platformifier interface.
platformifier/platformifier.go Updates platformifier interface and orchestration to collect/merge returned file maps.
platformifier/nextjs.go Removes Next.js platformifier implementation.
platformifier/models.go Changes WorkingDirectory to fs.FS, removes Root, and uses any for Locations.
platformifier/laravel.go Migrates Laravel platformifier to fs.FS and new return signature.
platformifier/generic_test.go Updates generic platformifier tests to validate returned file maps (uses fstest.MapFS).
platformifier/generic.go Renders templates into an in-memory file map instead of writing files.
platformifier/fs_mock_test.go Removes generated gomock for the old custom FS interface.
platformifier/fs.go Removes the custom FS interface and OS filesystem implementation.
platformifier/django_test.go Updates Django platformifier tests to use fstest.MapFS and validate returned file map behavior.
platformifier/django.go Migrates Django platformifier to produce file map outputs and read inputs via fs.FS.
internal/utils/utils.go Updates file search and JSON/TOML helpers to accept fs.FS.
internal/question/working_directory.go Sets WorkingDirectory to os.DirFS(cwd) and stores OS path in Cwd.
internal/question/web_command.go Updates discovery to use fs.FS + updated utils helpers.
internal/question/stack.go Updates stack detection to use fs.FS + updated utils helpers.
internal/question/name.go Uses Cwd to derive default application name.
internal/question/models/answer.go Changes WorkingDirectory to fs.FS, adds Cwd, and adapts ToUserInput() accordingly.
internal/question/locations.go Updates PHP index.php discovery to use fs.FS utils.
internal/question/files_overwrite.go Uses fs.Stat against WorkingDirectory; prints Cwd in messaging.
internal/question/done.go Prints Cwd for suggested git commands.
internal/question/deploy_command.go Uses fs.FS-based discovery for Django manage.py path.
internal/question/dependency_manager.go Updates lockfile discovery to use fs.FS utils.
internal/question/build_steps_test.go Seeds WorkingDirectory with fstest.MapFS in tests.
internal/question/build_steps.go Uses fs.FS-based discovery for Node/Django-related steps.
internal/question/application_root.go Sets ApplicationRoot via fs.FS-based discovery and path.Dir.
commands/platformify.go Writes returned file map to disk after platformifier runs.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +87 to +91
continue
}
if err := os.WriteFile(filePath, contents, 0o664); err != nil {
fmt.Fprintf(stderr, "Could not write file %s: %s\n", file, err)
continue
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

File write failures are currently logged and then skipped (continue), but the command still proceeds to the "Done" step and returns nil. That can leave the project partially configured without signaling failure. Consider collecting these write errors and returning a non-nil error (or at least failing fast) so callers/users know configuration did not complete successfully.

Suggested change
continue
}
if err := os.WriteFile(filePath, contents, 0o664); err != nil {
fmt.Fprintf(stderr, "Could not write file %s: %s\n", file, err)
continue
return fmt.Errorf("could not create parent directories of file %s: %w", file, err)
}
if err := os.WriteFile(filePath, contents, 0o664); err != nil {
fmt.Fprintf(stderr, "Could not write file %s: %s\n", file, err)
return fmt.Errorf("could not write file %s: %w", file, err)

Copilot uses AI. Check for mistakes.
}

return nil
return nil, nil
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

Platformify returns (nil, nil) here. Returning a nil map on success forces callers to defensively handle nil and is inconsistent with other platformifiers that return an empty map when they have nothing to write. Consider returning an empty map instead (and/or returning the collected files from this platformifier when applicable).

Suggested change
return nil, nil
return map[string][]byte{}, nil

Copilot uses AI. Check for mistakes.
}
if requirements, ok := utils.GetJSONValue([]string{"require"}, composerJSONPath, true); ok {
if requirements, ok := utils.GetJSONValue(answers.WorkingDirectory, []string{"require"}, composerJSONPath, true); ok {
if requirementsVal, requirementsOK := requirements.(map[string]interface{}); requirementsOK {
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

After switching GetJSONValue to return map[string]any, the "require" object will be decoded as map[string]any. This assertion to map[string]interface{} will never succeed, so Symfony/Shopware/Ibexa dependency detection won’t run. Update the assertion to map[string]any (and adjust the loop accordingly).

Suggested change
if requirementsVal, requirementsOK := requirements.(map[string]interface{}); requirementsOK {
if requirementsVal, requirementsOK := requirements.(map[string]any); requirementsOK {

Copilot uses AI. Check for mistakes.
}

managePyPath, _ = filepath.Rel(path.Join(answers.WorkingDirectory, answers.ApplicationRoot), managePyPath)
managePyPath, _ = filepath.Rel(answers.ApplicationRoot, managePyPath)
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

managePyPath and answers.ApplicationRoot are fs paths (slash-separated) used to generate commands that run in a Linux container. Using filepath.Rel here can produce OS-specific separators (e.g., backslashes on Windows), yielding an invalid path in the generated command. Prefer path.Rel (or otherwise force '/' separators) for these in-repo paths.

Copilot uses AI. Check for mistakes.
Comment on lines +30 to +31
WorkingDirectory fs.FS
Cwd string
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

Answers.WorkingDirectory is now fs.FS and Cwd is a new field, but neither has a json tag. If Answers is ever marshaled/unmarshaled, this will either serialize an implementation-specific value for WorkingDirectory or change the JSON shape unexpectedly (Cwd becomes "Cwd"). Consider tagging WorkingDirectory as json:"-" and adding an explicit tag for Cwd (e.g., json:"cwd") to avoid accidental persistence/API changes.

Suggested change
WorkingDirectory fs.FS
Cwd string
WorkingDirectory fs.FS `json:"-"`
Cwd string `json:"cwd"`

Copilot uses AI. Check for mistakes.
Comment on lines 152 to 154
if keywordsVal, ok := keywords.([]string); ok && slices.Contains(keywordsVal, "shopware") {
hasShopwareDependencies = true
break
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

GetJSONValue unmarshals JSON into map[string]any, so fields like "keywords" will decode as []any, not []string. This type assertion will always fail, preventing detection of Shopware via composer.json keywords. Consider handling []any (or unmarshalling into a typed struct) and converting elements to strings.

Suggested change
if keywordsVal, ok := keywords.([]string); ok && slices.Contains(keywordsVal, "shopware") {
hasShopwareDependencies = true
break
switch kv := keywords.(type) {
case []interface{}:
keywordStrings := make([]string, 0, len(kv))
for _, item := range kv {
if s, ok := item.(string); ok {
keywordStrings = append(keywordStrings, s)
}
}
if slices.Contains(keywordStrings, "shopware") {
hasShopwareDependencies = true
break
}
case []string:
if slices.Contains(kv, "shopware") {
hasShopwareDependencies = true
break
}

Copilot uses AI. Check for mistakes.
managePyPath := utils.FindFile(answers.WorkingDirectory, answers.ApplicationRoot, managePyFile)
if managePyPath != "" {
managePyPath, _ = filepath.Rel(path.Join(answers.WorkingDirectory, answers.ApplicationRoot), managePyPath)
managePyPath, _ = filepath.Rel(answers.ApplicationRoot, managePyPath)
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

managePyPath and answers.ApplicationRoot are fs paths intended for use inside the generated deploy command (Linux container). filepath.Rel can introduce OS-specific separators (notably '\' on Windows), producing an invalid command path. Use path.Rel or normalize to '/' before formatting the command.

Suggested change
managePyPath, _ = filepath.Rel(answers.ApplicationRoot, managePyPath)
managePyPath, _ = filepath.Rel(answers.ApplicationRoot, managePyPath)
managePyPath = filepath.ToSlash(managePyPath)

Copilot uses AI. Check for mistakes.
Comment on lines 63 to 66
wsgiRel, _ := filepath.Rel(
path.Join(answers.WorkingDirectory, answers.ApplicationRoot),
answers.ApplicationRoot,
path.Dir(path.Dir(wsgiPath)),
)
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

This uses filepath.Rel on fs paths (wsgiPath/answers.ApplicationRoot) that are slash-separated and later used in runtime commands. On Windows, filepath.Rel may return backslash-separated paths, which would generate invalid gunicorn/pythonpath values for a Linux container. Prefer path.Rel (or normalize to '/') for repository-relative paths.

Copilot uses AI. Check for mistakes.
Comment on lines 119 to 121
Stack: getStack(a.Stack),
Root: "",
ApplicationRoot: filepath.Join(string(os.PathSeparator), a.ApplicationRoot),
Name: a.Name,
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

ToUserInput currently prefixes ApplicationRoot with an OS path separator (e.g., "/app" or "\app"). With the new fs.FS-based file discovery (fs.WalkDir/os.DirFS), leading separators (and Windows backslashes) make paths invalid for fs.FS operations and can prevent stack-specific platformifiers from finding project files. Consider keeping ApplicationRoot as a slash-separated relative path for fs traversal, and only rendering a leading "/" (if needed) at template time.

Copilot uses AI. Check for mistakes.
Comment on lines +84 to +90
filePath := path.Join(answers.Cwd, file)
if err := os.MkdirAll(path.Dir(filePath), os.ModeDir|os.ModePerm); err != nil {
fmt.Fprintf(stderr, "Could not create parent directories of file %s: %s\n", file, err)
continue
}
if err := os.WriteFile(filePath, contents, 0o664); err != nil {
fmt.Fprintf(stderr, "Could not write file %s: %s\n", file, err)
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

This code uses the path package (slash-based) to build OS file paths (answers.Cwd + template paths). On Windows, path.Join/path.Dir won’t handle volume names or separators correctly. Prefer filepath.Join/filepath.Dir for OS paths, and use a standard dir/file mode (e.g., 0o755 for MkdirAll and 0o644 for WriteFile) rather than os.ModeDir|os.ModePerm.

Copilot uses AI. Check for mistakes.
Antonis Kalipetis and others added 2 commits March 24, 2026 01:18
Abstract the filesystem using Go's standard fs.FS interface and change
Platformify() to return a map[string][]byte of generated files instead
of writing them to disk. This enables callers (like source-integration-
apps) to operate on in-memory filesystems and handle file output
themselves.

Changes:
- internal/utils: all file functions take fs.FS as first parameter,
  use fs.WalkDir/fs.ReadFile instead of os equivalents
- internal/question/models: WorkingDirectory becomes fs.FS, add Cwd
  string field for the OS path
- internal/question/*: update all callers for new utils signatures
- platformifier: interface returns (map[string][]byte, error),
  generic/django/laravel collect files in map instead of writing
- platformifier: remove custom FS interface (fs.go), mock (fs_mock_test),
  mock generator (platformifier_mock_test), and nextjs platformifier
- platformifier: adapt all tests to use fstest.MapFS and new interface
- commands/platformify.go: write returned file map to disk
- validator: use os.DirFS for FindAllFiles call

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- utils.go: use "/" instead of os.PathSeparator in FindFile sort,
  since fs.WalkDir always returns forward-slash paths
- stack.go: fix keywords type assertion from []string to []any,
  since JSON arrays unmarshal as []any
- laravel.go: return empty map instead of nil for consistency
  with other platformifiers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@pjcdawkins pjcdawkins force-pushed the refactor/abstract-fs-2-fsys branch from 9fd9c6f to ac58a58 Compare March 24, 2026 01:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants