From acad72b8d84b857eea95b9b3a9156f94977b7cde Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Fri, 25 Sep 2026 10:37:01 +0200 Subject: [PATCH] perf(project): avoid copying vendor during archive staging Preserve prebuilt dependencies when Composer installation is disabled and report copy progress with CI-aware sections. --- internal/projectbuild/archive.go | 11 ++++- internal/projectbuild/archive_test.go | 59 +++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/internal/projectbuild/archive.go b/internal/projectbuild/archive.go index 37265824e..9abd37959 100644 --- a/internal/projectbuild/archive.go +++ b/internal/projectbuild/archive.go @@ -12,6 +12,7 @@ import ( cp "github.com/otiai10/copy" "github.com/shopware/shopware-cli/internal/archiver" + "github.com/shopware/shopware-cli/internal/ci" "github.com/shopware/shopware-cli/internal/shop" "github.com/shopware/shopware-cli/logging" ) @@ -28,6 +29,7 @@ type ArchiveOptions struct { // PackageArchive builds a private, temporary copy and publishes a tar.gz. // Existing output files are never overwritten. This does not deploy anything. +// Composer rebuilds vendor unless installation is disabled. // Hooks are trusted project code, not sandboxed processes. func PackageArchive(ctx context.Context, root string, cfg *shop.Config, env *shop.EnvironmentConfig, opts ArchiveOptions) (string, error) { opts.Build.MirrorPathRepositories = true @@ -87,7 +89,11 @@ func packageArchive(ctx context.Context, root string, cfg *shop.Config, opts Arc return "", err } stage := filepath.Join(temp, "project") - if err := copyPackageSource(ctx, root, stage, temp, func(name string) bool { return archiveExcluded(name, false) }); err != nil { + excludeSource := func(name string) bool { + // Keep prebuilt dependencies only when Composer installation is disabled. + return (name == "vendor" && !cfg.DisableComposerInstall) || archiveExcluded(name, false) + } + if err := copyPackageSource(ctx, root, stage, temp, excludeSource); err != nil { return "", fmt.Errorf("copy project: %w", err) } if err := validateBuildSymlinks(ctx, stage); err != nil { @@ -130,6 +136,9 @@ func writeDeploymentConfig(stage string, cfg *shop.Config) error { // copyPackageSource copies links without following them. The caller validates // the copied links before building in the staging tree. func copyPackageSource(ctx context.Context, root, stage, temp string, exclude func(string) bool) error { + section := ci.Start("Copying project") + defer section.End() + return cp.Copy(root, stage, cp.Options{ PreserveTimes: true, OnSymlink: func(string) cp.SymlinkAction { return cp.Shallow }, diff --git a/internal/projectbuild/archive_test.go b/internal/projectbuild/archive_test.go index f0f18feed..845757331 100644 --- a/internal/projectbuild/archive_test.go +++ b/internal/projectbuild/archive_test.go @@ -118,6 +118,65 @@ func TestPackageArchiveUsesTemporaryCopy(t *testing.T) { } } +func TestPackageArchiveCopiesVendorOnlyWhenComposerInstallDisabled(t *testing.T) { + for _, tc := range []struct { + name string + disableComposerInstall bool + }{ + {"install dependencies in staging", false}, + {"use prebuilt dependencies", true}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + testhelper.WriteFile(t, filepath.Join(root, "composer.json"), "{}") + testhelper.WriteFile(t, filepath.Join(root, "vendor/local-only.txt"), "local dependency") + // Nested vendor directories can be application sources, not Composer output. + testhelper.WriteFile(t, filepath.Join(root, "src/vendor/source.txt"), "nested source") + cfg := &shop.Config{DisableComposerInstall: tc.disableComposerInstall} + reference, err := packageArchive(t.Context(), root, cfg, ArchiveOptions{}, func(_ context.Context, stage string) error { + if tc.disableComposerInstall { + assert.FileExists(t, filepath.Join(stage, "vendor/local-only.txt")) + } else { + assert.NoDirExists(t, filepath.Join(stage, "vendor")) + } + assert.FileExists(t, filepath.Join(stage, "src/vendor/source.txt")) + testhelper.WriteFile(t, filepath.Join(stage, "vendor/built.txt"), "built dependency") + return nil + }) + require.NoError(t, err) + contents := readArchive(t, reference) + assert.Equal(t, "built dependency", contents["vendor/built.txt"], "staging exclusions must not exclude built dependencies from the archive") + _, copiedVendor := contents["vendor/local-only.txt"] + assert.Equal(t, tc.disableComposerInstall, copiedVendor) + assert.Equal(t, "nested source", contents["src/vendor/source.txt"]) + source, err := os.ReadFile(filepath.Join(root, "vendor/local-only.txt")) + require.NoError(t, err) + assert.Equal(t, "local dependency", string(source)) + assert.NoFileExists(t, filepath.Join(root, "vendor/built.txt")) + }) + } +} + +func TestPackageArchiveSkipsSourceVendorSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks require privileges on Windows") + } + root := t.TempDir() + outside := t.TempDir() + testhelper.WriteFile(t, filepath.Join(outside, "local-only.txt"), "external dependency") + require.NoError(t, os.Symlink(outside, filepath.Join(root, "vendor"))) + reference, err := packageArchive(t.Context(), root, &shop.Config{}, ArchiveOptions{}, func(_ context.Context, stage string) error { + _, err := os.Lstat(filepath.Join(stage, "vendor")) + assert.ErrorIs(t, err, os.ErrNotExist) + testhelper.WriteFile(t, filepath.Join(stage, "vendor/built.txt"), "built dependency") + return nil + }) + require.NoError(t, err) + assert.Equal(t, "built dependency", readArchive(t, reference)["vendor/built.txt"]) + assert.FileExists(t, filepath.Join(outside, "local-only.txt")) + assert.NoFileExists(t, filepath.Join(outside, "built.txt")) +} + func TestPackageArchiveFailureCleansUp(t *testing.T) { root := t.TempDir() output := filepath.Join(t.TempDir(), "archive.tar.gz")