Skip to content
Draft
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
514 changes: 514 additions & 0 deletions cmd/project/project_upgrade.go

Large diffs are not rendered by default.

114 changes: 114 additions & 0 deletions cmd/project/project_upgrade_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package project

import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"testing"

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

func jsonMarshal(v any) ([]byte, error) {
return json.MarshalIndent(v, "", " ")
}

func gitCmd(t *testing.T, dir string, args ...string) {
t.Helper()
c := exec.CommandContext(t.Context(), "git", args...)
c.Dir = dir
out, err := c.CombinedOutput()
if err != nil {
t.Fatalf("git %v failed: %s", args, string(out))
}
}

func setupTestRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
gitCmd(t, dir, "init")
gitCmd(t, dir, "config", "commit.gpgsign", "false")
gitCmd(t, dir, "config", "user.name", "test")
gitCmd(t, dir, "config", "user.email", "test@example.com")
require.NoError(t, os.WriteFile(filepath.Join(dir, "seed"), []byte("seed"), 0o644))
gitCmd(t, dir, "add", "seed")
gitCmd(t, dir, "commit", "-m", "seed", "--no-verify", "--no-gpg-sign")
return dir
}

func TestEnsureCleanGitTreeSkipsNonRepo(t *testing.T) {
t.Parallel()
dir := t.TempDir()
assert.NoError(t, ensureCleanGitTree(t.Context(), dir, false))
}

func TestEnsureCleanGitTreeAllowsCleanRepo(t *testing.T) {
t.Parallel()
dir := setupTestRepo(t)
assert.NoError(t, ensureCleanGitTree(t.Context(), dir, false))
}

func TestEnsureCleanGitTreeRejectsDirtyRepo(t *testing.T) {
t.Parallel()
dir := setupTestRepo(t)
require.NoError(t, os.WriteFile(filepath.Join(dir, "untracked"), []byte("x"), 0o644))

err := ensureCleanGitTree(t.Context(), dir, false)
require.Error(t, err)
assert.Contains(t, err.Error(), "working tree must be clean")
assert.Contains(t, err.Error(), "untracked")
}

func TestEnsureCleanGitTreeAllowDirtyFlagBypassesCheck(t *testing.T) {
t.Parallel()
dir := setupTestRepo(t)
require.NoError(t, os.WriteFile(filepath.Join(dir, "untracked"), []byte("x"), 0o644))

assert.NoError(t, ensureCleanGitTree(t.Context(), dir, true))
}

func writeInstalledJSON(t *testing.T, projectDir string, packages []map[string]any) {
t.Helper()
installedDir := filepath.Join(projectDir, "vendor", "composer")
require.NoError(t, os.MkdirAll(installedDir, 0o755))
body, err := jsonMarshal(map[string]any{"packages": packages})
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(installedDir, "installed.json"), body, 0o644))
}

func TestEnsureAllPluginsAreComposerManagedAllowsTrackedDirectories(t *testing.T) {
t.Parallel()
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Tracked"), 0o755))
writeInstalledJSON(t, dir, []map[string]any{
{
"name": "vendor/tracked",
"type": "shopware-platform-plugin",
"install-path": "../../custom/plugins/Tracked",
},
})

assert.NoError(t, ensureAllPluginsAreComposerManaged(dir, false))
}

func TestEnsureAllPluginsAreComposerManagedRejectsOrphanedDirectory(t *testing.T) {
t.Parallel()
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Orphan"), 0o755))

err := ensureAllPluginsAreComposerManaged(dir, false)
require.Error(t, err)
assert.Contains(t, err.Error(), "not tracked by composer")
assert.Contains(t, err.Error(), "Orphan")
assert.Contains(t, err.Error(), "autofix composer-plugins")
}

func TestEnsureAllPluginsAreComposerManagedAllowFlagBypasses(t *testing.T) {
t.Parallel()
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "custom", "plugins", "Orphan"), 0o755))

assert.NoError(t, ensureAllPluginsAreComposerManaged(dir, true))
}
32 changes: 31 additions & 1 deletion internal/account-api/updates.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,44 @@ type UpdateCheckExtensionCompatibility struct {
Status UpdateCheckExtensionCompatibilityStatus `json:"status"`
}

// Plugin compatibility status names returned by the store autoupdate
// endpoint. These mirror the constants in Shopware's
// Core\Framework\Update\Services\ExtensionCompatibility. The status `type`
// field is only a display color (green/red/…), so classification must key on
// the semantic `name` instead.
const (
// CompatibilityCompatible means the installed version already works with
// the target Shopware version.
CompatibilityCompatible = "compatible"
// CompatibilityUpdatableNow / CompatibilityUpdatableFuture mean the
// extension has a compatible release available ("With new Shopware
// version"): not a blocker, the constraint just needs to be bumped.
CompatibilityUpdatableNow = "updatableNow"
CompatibilityUpdatableFuture = "updatableFuture"
// CompatibilityNotCompatible means no compatible successor exists. This
// is the only genuine blocker.
CompatibilityNotCompatible = "notCompatible"
// CompatibilityNotInStore means the extension is not managed by the store.
CompatibilityNotInStore = "notInStore"
)

type UpdateCheckExtensionCompatibilityStatus struct {
Name string `json:"name"`
Label string `json:"label"`
Type string `json:"type"`
}

// IsBlocker reports whether this status prevents the upgrade. Only
// notCompatible (no compatible successor) blocks; updatableNow/updatableFuture
// are resolvable by bumping the extension constraint, so they are not blockers.
func (s UpdateCheckExtensionCompatibilityStatus) IsBlocker() bool {
return s.Type != "success" && s.Type != ""
return s.Name == CompatibilityNotCompatible
}

// IsUpdatable reports whether a compatible release exists that the installed
// version must be bumped to ("With new Shopware version").
func (s UpdateCheckExtensionCompatibilityStatus) IsUpdatable() bool {
return s.Name == CompatibilityUpdatableNow || s.Name == CompatibilityUpdatableFuture
}

func GetFutureExtensionUpdates(ctx context.Context, currentVersion string, futureVersion string, extensions []UpdateCheckExtension) ([]UpdateCheckExtensionCompatibility, error) {
Expand Down
55 changes: 55 additions & 0 deletions internal/account-api/updates_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package account_api

import (
"testing"

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

func TestUpdateCheckExtensionCompatibilityStatusClassification(t *testing.T) {
t.Parallel()

tests := []struct {
name string
status UpdateCheckExtensionCompatibilityStatus
isBlocker bool
isUpdatable bool
}{
{
name: "compatible",
status: UpdateCheckExtensionCompatibilityStatus{Name: CompatibilityCompatible, Type: "green"},
},
{
name: "updatable now is not a blocker",
status: UpdateCheckExtensionCompatibilityStatus{Name: CompatibilityUpdatableNow, Type: "yellow"},
isUpdatable: true,
},
{
name: "updatable future is not a blocker",
status: UpdateCheckExtensionCompatibilityStatus{Name: CompatibilityUpdatableFuture, Type: "yellow"},
isUpdatable: true,
},
{
name: "not compatible blocks",
status: UpdateCheckExtensionCompatibilityStatus{Name: CompatibilityNotCompatible, Type: "red"},
isBlocker: true,
},
{
name: "not in store is informational",
status: UpdateCheckExtensionCompatibilityStatus{Name: CompatibilityNotInStore},
},
{
name: "empty status is not a blocker",
status: UpdateCheckExtensionCompatibilityStatus{},
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.isBlocker, tt.status.IsBlocker())
assert.Equal(t, tt.isUpdatable, tt.status.IsUpdatable())
})
}
}
7 changes: 7 additions & 0 deletions internal/flexmigrator/cleanup.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,13 @@ func Cleanup(project string) error {
}
}

return CleanupByHash(project)
}

// CleanupByHash removes recipe-managed files that still match a known stale
// template hash. This makes sure the recipe can recreate them on the next
// composer install.
func CleanupByHash(project string) error {
for file, md5s := range cleanupByMd5 {
content, err := os.ReadFile(path.Join(project, file))
if err != nil {
Expand Down
30 changes: 30 additions & 0 deletions internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,33 @@ func Init(ctx context.Context, repo string) error {
_, err := runGit(ctx, repo, "init")
return err
}

// IsRepository reports whether path is inside a git working tree.
// Returns false (no error) when git is not installed or the directory is not
// tracked by git.
func IsRepository(ctx context.Context, path string) bool {
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--is-inside-work-tree")
cmd.Dir = path
out, err := cmd.Output()
if err != nil {
return false
}
return strings.TrimSpace(string(out)) == "true"
}

// WorkingTreeStatus reports the porcelain status of the working tree at repo.
// It returns the raw lines of `git status --porcelain`, one entry per changed
// file. An empty slice means the working tree is clean.
func WorkingTreeStatus(ctx context.Context, repo string) ([]string, error) {
cmd := exec.CommandContext(ctx, "git", "status", "--porcelain")
cmd.Dir = repo
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("git status: %w", err)
}
trimmed := strings.TrimRight(string(out), "\n")
if trimmed == "" {
return nil, nil
}
return strings.Split(trimmed, "\n"), nil
}
42 changes: 42 additions & 0 deletions internal/git/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,45 @@ func prepareRepository(t *testing.T, tmpDir string) {
runCommand(t, tmpDir, "config", "user.name", "test")
runCommand(t, tmpDir, "config", "user.email", "test@test.de")
}

func TestIsRepositoryFalseForPlainDir(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
assert.False(t, IsRepository(t.Context(), tmpDir))
}

func TestIsRepositoryTrueForInitializedRepo(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
prepareRepository(t, tmpDir)
assert.True(t, IsRepository(t.Context(), tmpDir))
}

func TestWorkingTreeStatusCleanRepo(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
prepareRepository(t, tmpDir)
_ = os.WriteFile(filepath.Join(tmpDir, "a"), []byte("hi"), 0o644)
runCommand(t, tmpDir, "add", "a")
runCommand(t, tmpDir, "commit", "-m", "initial", "--no-verify", "--no-gpg-sign")

lines, err := WorkingTreeStatus(t.Context(), tmpDir)
assert.NoError(t, err)
assert.Empty(t, lines, "freshly committed repo should be clean")
}

func TestWorkingTreeStatusReportsUntrackedAndModified(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
prepareRepository(t, tmpDir)
_ = os.WriteFile(filepath.Join(tmpDir, "tracked.txt"), []byte("hi"), 0o644)
runCommand(t, tmpDir, "add", "tracked.txt")
runCommand(t, tmpDir, "commit", "-m", "initial", "--no-verify", "--no-gpg-sign")

_ = os.WriteFile(filepath.Join(tmpDir, "tracked.txt"), []byte("modified"), 0o644)
_ = os.WriteFile(filepath.Join(tmpDir, "untracked.txt"), []byte("new"), 0o644)

lines, err := WorkingTreeStatus(t.Context(), tmpDir)
assert.NoError(t, err)
assert.Len(t, lines, 2)
}
48 changes: 48 additions & 0 deletions internal/packagist/constraints.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package packagist

import (
"strings"

"github.com/shyim/go-version"
)

// ConstraintsSatisfiedBy reports whether every constraint that requires
// declares for a package named in packages is satisfied by target.
// Constraints for packages not listed in packages are ignored, and packages
// that declare no constraint are treated as satisfied. An unparseable
// constraint is treated as not satisfied.
func ConstraintsSatisfiedBy(requires map[string]string, packages []string, target *version.Version) bool {
for _, name := range packages {
constraint, ok := requires[name]
if !ok {
continue
}

c, err := version.NewConstraint(constraint)
if err != nil {
return false
}

if !c.Check(target) {
return false
}
}

return true
}

// BumpConstraint turns a concrete version (e.g. "2.3.4") into a caret
// constraint ("^2.3.4") suitable for a composer.json require entry. Values
// that already look like a constraint (containing range/wildcard operators)
// are returned unchanged.
func BumpConstraint(version string) string {
if version == "" {
return version
}

if strings.ContainsAny(version, "^~><*|, ") {
return version
}

return "^" + version
}
Loading