diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index a18d144..9c32e4e 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -17,7 +17,7 @@ jobs: name: PHP ${{ matrix.php-versions }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@master @@ -38,14 +38,15 @@ jobs: contents: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref }} - name: "laravel-pint" uses: aglipanci/laravel-pint-action@latest with: configPath: './pint.json' - name: Commit changes - uses: stefanzweifel/git-auto-commit-action@v4 + uses: stefanzweifel/git-auto-commit-action@v7 with: commit_message: PHP Linting (Pint) - skip_fetch: true diff --git a/README.md b/README.md index 2269594..3adad80 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,10 @@ [![PHP Version](https://img.shields.io/badge/PHP-8.4%2B-777BB4?logo=php&logoColor=white)](#requirements) [![Composer](https://img.shields.io/badge/Composer-2.x-885630?logo=composer&logoColor=white)](#requirements) -[![Tests](https://github.com/sauce-base/module-installer/actions/workflows/php.yml/badge.svg)](https://github.com/sauce-base/module-installer/actions/workflows/php.yml) +[![Tests](https://github.com/saucebase-dev/module-installer/actions/workflows/php.yml/badge.svg)](https://github.com/saucebase-dev/module-installer/actions/workflows/php.yml) [![License](https://img.shields.io/badge/License-MIT-0A7EA4)](#license) -This Composer plugin installs Sauce Base modules into the correct directory. It ships with `sauce-base/core`, so every module that your project requires is placed where Sauce Base can find and load it. The installer stays compatible with [nWidart/laravel-modules](https://github.com/nWidart/laravel-modules) and offers a Sauce Base-focused alternative to [joshbrw/laravel-module-installer](https://github.com/joshbrw/laravel-module-installer). +This Composer plugin installs Sauce Base modules into the correct directory. It ships with `saucebase-dev/saucebase`, so every module that your project requires is placed where Sauce Base can find and load it. The installer stays compatible with [nWidart/laravel-modules](https://github.com/nWidart/laravel-modules) and offers a Sauce Base-focused alternative to [joshbrw/laravel-module-installer](https://github.com/joshbrw/laravel-module-installer). ## How It Works @@ -18,11 +18,11 @@ This Composer plugin installs Sauce Base modules into the correct directory. It - PHP 8.4 or newer - Composer 2.x -- A project based on `sauce-base/core` (the core already requires this plugin) +- A project based on `saucebase-dev/saucebase` (the core already requires this plugin) ## Installation -`sauce-base/core` already requires this package. When you install the core, Composer pulls in the plugin and activates it through the `Saucebase\\ModuleInstaller\\Plugin` class, so a typical Sauce Base project needs no extra configuration. +`saucebase-dev/saucebase` already requires this package. When you install the core, Composer pulls in the plugin and activates it through the `Saucebase\\ModuleInstaller\\Plugin` class, so a typical Sauce Base project needs no extra configuration. Need the installer for a different Composer project? Require it directly: diff --git a/composer.json b/composer.json index 1fd6135..d314735 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,9 @@ ], "require": { "php": "^8.4", - "composer-plugin-api": "^2.0" + "composer-plugin-api": "^2.0", + "symfony/finder": "^8.0", + "symfony/process": "^8.0" }, "require-dev": { "composer/composer": "^2.0", diff --git a/composer.lock b/composer.lock index f089680..ffd4e74 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,142 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "bfb13925727acd3cb3da13b9924095f5", - "packages": [], + "content-hash": "3b9bd3ebf86ea0e34b28472b9de4c0f3", + "packages": [ + { + "name": "symfony/finder", + "version": "v8.0.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/441404f09a54de6d1bd6ad219e088cdf4c91f97c", + "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "symfony/filesystem": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v8.0.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-29T09:41:02+00:00" + }, + { + "name": "symfony/process", + "version": "v8.0.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", + "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v8.0.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-26T15:08:38+00:00" + } + ], "packages-dev": [ { "name": "composer/ca-bundle", @@ -2993,74 +3127,6 @@ ], "time": "2026-02-25T16:59:43+00:00" }, - { - "name": "symfony/finder", - "version": "v8.0.6", - "source": { - "type": "git", - "url": "https://github.com/symfony/finder.git", - "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/441404f09a54de6d1bd6ad219e088cdf4c91f97c", - "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c", - "shasum": "" - }, - "require": { - "php": ">=8.4" - }, - "require-dev": { - "symfony/filesystem": "^7.4|^8.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Finder\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Finds files and directories via an intuitive fluent interface", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/finder/tree/v8.0.6" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-01-29T09:41:02+00:00" - }, { "name": "symfony/polyfill-ctype", "version": "v1.33.0", @@ -3720,71 +3786,6 @@ ], "time": "2025-06-24T13:30:11+00:00" }, - { - "name": "symfony/process", - "version": "v8.0.5", - "source": { - "type": "git", - "url": "https://github.com/symfony/process.git", - "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", - "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", - "shasum": "" - }, - "require": { - "php": ">=8.4" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Process\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Executes commands in sub-processes", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/process/tree/v8.0.5" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-01-26T15:08:38+00:00" - }, { "name": "symfony/service-contracts", "version": "v3.6.1", diff --git a/src/Installer.php b/src/Installer.php index 1687bcb..0885f73 100644 --- a/src/Installer.php +++ b/src/Installer.php @@ -11,6 +11,8 @@ use React\Promise\PromiseInterface; use Saucebase\ModuleInstaller\Exceptions\ModuleInstallerException; use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem; +use Symfony\Component\Finder\Finder; +use Symfony\Component\Process\Process; /** * @property PartialComposer $composer @@ -198,16 +200,65 @@ protected function stashModuleDir(string $path): ?string } /** - * Mirror the stash directory back into the install path so user edits win. + * Download the given package version into $basePath using Composer's DownloadManager + * so the dist cache is reused. Returns a promise that resolves when ready. */ - protected function restoreStash(string $stashPath, string $installPath): void + protected function downloadBase(PackageInterface $initial, string $basePath): PromiseInterface { - (new SymfonyFilesystem)->mirror( - $stashPath, - $installPath, - null, - ['override' => true, 'delete' => false] - ); + $dm = $this->composer->getDownloadManager(); + + return $dm->download($initial, $basePath) + ->then(fn () => $dm->install($initial, $basePath)); + } + + /** + * 3-way merge stash (user's copy) against base (original version) into install (new version). + * + * Decision table: + * stash + base + install → git merge-file (3-way merge) + * stash + base, no install → upstream deleted the file — leave it gone + * stash only (no base) → user-added file — copy to install + * install only → upstream-added file — already there, no action needed + */ + protected function mergeStash(string $stash, string $base, string $install): void + { + $finder = (new Finder)->files()->in($stash); + + foreach ($finder as $file) { + $rel = $file->getRelativePathname(); + $stashFile = $stash.'/'.$rel; + $baseFile = $base.'/'.$rel; + $newFile = $install.'/'.$rel; + + if (! file_exists($baseFile)) { + // User-added file (not in original dist) — always keep + (new SymfonyFilesystem)->copy($stashFile, $newFile, true); + + continue; + } + + if (! file_exists($newFile)) { + // Upstream deleted this file — respect the deletion, do not restore + continue; + } + + // All three versions exist — 3-way merge + $merged = $stashFile.'.merge-work'; + copy($stashFile, $merged); + + $process = new Process( + ['git', 'merge-file', '-L', 'yours', '-L', 'original', '-L', 'upstream', + $merged, $baseFile, $newFile] + ); + $process->run(); + + // exit code >0 = conflict markers inserted, but result is still usable + if ($process->getExitCode() > 0) { + $this->io->writeError(" Merge conflict in $rel — conflict markers inserted"); + } + + rename($merged, $newFile); + } } /** @@ -232,25 +283,36 @@ public function install(InstalledRepositoryInterface $repo, PackageInterface $pa public function update(InstalledRepositoryInterface $repo, PackageInterface $initial, PackageInterface $target): ?PromiseInterface { $stashPath = null; + $basePath = null; if ($this->getUpdateStrategy() === self::UPDATE_STRATEGY_MERGE) { $stashPath = $this->stashModuleDir($this->getInstallPath($initial)); + if ($stashPath !== null) { + $basePath = sys_get_temp_dir().'/module-base-'.uniqid('', true); + } } - $promise = parent::update($repo, $initial, $target); + $prepareBase = ($basePath !== null) + ? $this->downloadBase($initial, $basePath) + : \React\Promise\resolve(null); - return $promise->then( - function () use ($target, $stashPath) { + return $prepareBase->then(fn () => parent::update($repo, $initial, $target))->then( + function () use ($target, $stashPath, $basePath) { $this->removeExcludedDirectories($target); if ($stashPath !== null) { - $this->restoreStash($stashPath, $this->getInstallPath($target)); + $installPath = $this->getInstallPath($target); + $this->mergeStash($stashPath, $basePath, $installPath); (new Filesystem)->removeDirectory($stashPath); + (new Filesystem)->removeDirectory($basePath); } }, - function (\Throwable $e) use ($stashPath) { + function (\Throwable $e) use ($stashPath, $basePath) { if ($stashPath !== null) { (new Filesystem)->removeDirectory($stashPath); } + if ($basePath !== null) { + (new Filesystem)->removeDirectory($basePath); + } throw $e; } ); diff --git a/tests/ModuleInstallerTest.php b/tests/ModuleInstallerTest.php index 53c0177..30d13f6 100644 --- a/tests/ModuleInstallerTest.php +++ b/tests/ModuleInstallerTest.php @@ -49,9 +49,9 @@ public function callStashModuleDir(string $path): ?string return parent::stashModuleDir($path); } - public function callRestoreStash(string $from, string $to): void + public function callMergeStash(string $stash, string $base, string $install): void { - parent::restoreStash($from, $to); + parent::mergeStash($stash, $base, $install); } } @@ -214,56 +214,169 @@ public function test_stash_returns_null_when_dir_does_not_exist(): void } // ------------------------------------------------------------------------- - // restoreStash + // mergeStash // ------------------------------------------------------------------------- - public function test_restore_stash_copies_user_files_into_install_path(): void + private function makeTempDir(): string + { + $path = sys_get_temp_dir().'/merge-test-'.uniqid('', true); + mkdir($path, 0755, true); + + return $path; + } + + public function test_merge_stash_applies_upstream_changes_to_unedited_file(): void { $io = $this->createStub(IOInterface::class); $installer = new TestableInstaller($io, null); - $stash = sys_get_temp_dir().'/module-stash-test-restore-'.uniqid('', true); - $install = sys_get_temp_dir().'/module-install-test-'.uniqid('', true); + $stash = $this->makeTempDir(); + $base = $this->makeTempDir(); + $install = $this->makeTempDir(); - mkdir($stash); - mkdir($install); - file_put_contents($stash.'/custom.txt', 'user edit'); + // base == stash (user made no edits), upstream changed the file + file_put_contents($stash.'/api.php', "line1\nline2\n"); + file_put_contents($base.'/api.php', "line1\nline2\n"); + file_put_contents($install.'/api.php', "line1\nline2-upstream\n"); - $installer->callRestoreStash($stash, $install); + $installer->callMergeStash($stash, $base, $install); - $this->assertFileExists($install.'/custom.txt'); - $this->assertSame('user edit', file_get_contents($install.'/custom.txt')); + $this->assertSame("line1\nline2-upstream\n", file_get_contents($install.'/api.php')); - // Cleanup $fs = new Filesystem; $fs->remove($stash); + $fs->remove($base); $fs->remove($install); } - public function test_restore_stash_leaves_new_upstream_files_intact(): void + public function test_merge_stash_preserves_user_edits_when_upstream_unchanged(): void { $io = $this->createStub(IOInterface::class); $installer = new TestableInstaller($io, null); - $stash = sys_get_temp_dir().'/module-stash-test-upstream-'.uniqid('', true); - $install = sys_get_temp_dir().'/module-install-test-upstream-'.uniqid('', true); + $stash = $this->makeTempDir(); + $base = $this->makeTempDir(); + $install = $this->makeTempDir(); + + // User edited line 2; upstream kept the file as-is + file_put_contents($stash.'/api.php', "line1\nline2-user\n"); + file_put_contents($base.'/api.php', "line1\nline2\n"); + file_put_contents($install.'/api.php', "line1\nline2\n"); + + $installer->callMergeStash($stash, $base, $install); + + $this->assertSame("line1\nline2-user\n", file_get_contents($install.'/api.php')); - mkdir($stash); - mkdir($install); + $fs = new Filesystem; + $fs->remove($stash); + $fs->remove($base); + $fs->remove($install); + } - // Upstream added a new file during update - file_put_contents($install.'/new-upstream.txt', 'new from upstream'); - // User had a customised file in the stash - file_put_contents($stash.'/custom.txt', 'user edit'); + public function test_merge_stash_merges_non_overlapping_edits(): void + { + $io = $this->createStub(IOInterface::class); + $installer = new TestableInstaller($io, null); - $installer->callRestoreStash($stash, $install); + $stash = $this->makeTempDir(); + $base = $this->makeTempDir(); + $install = $this->makeTempDir(); - $this->assertFileExists($install.'/new-upstream.txt'); - $this->assertFileExists($install.'/custom.txt'); + $baseContent = "line1\nline2\nline3\nline4\nline5\n"; + $userContent = "line1-user\nline2\nline3\nline4\nline5\n"; // user changed line 1 + $upstreamContent = "line1\nline2\nline3\nline4\nline5-up\n"; // upstream changed line 5 + + file_put_contents($stash.'/api.php', $userContent); + file_put_contents($base.'/api.php', $baseContent); + file_put_contents($install.'/api.php', $upstreamContent); + + $installer->callMergeStash($stash, $base, $install); + + $result = file_get_contents($install.'/api.php'); + $this->assertStringContainsString('line1-user', $result); + $this->assertStringContainsString('line5-up', $result); + $this->assertStringNotContainsString('<<<<<<<', $result); + + $fs = new Filesystem; + $fs->remove($stash); + $fs->remove($base); + $fs->remove($install); + } + + public function test_merge_stash_inserts_conflict_markers_on_overlapping_edits(): void + { + $io = $this->createMock(IOInterface::class); + $io->expects($this->once())->method('writeError'); + + $installer = new TestableInstaller($io, null); + + $stash = $this->makeTempDir(); + $base = $this->makeTempDir(); + $install = $this->makeTempDir(); + + // Both sides changed the same line + file_put_contents($stash.'/api.php', "original\n"); + file_put_contents($base.'/api.php', "original\n"); + file_put_contents($install.'/api.php', "upstream-change\n"); + + // Make the stash differ from base on the same line + file_put_contents($stash.'/api.php', "user-change\n"); + + $installer->callMergeStash($stash, $base, $install); + + $result = file_get_contents($install.'/api.php'); + $this->assertStringContainsString('<<<<<<<', $result); + + $fs = new Filesystem; + $fs->remove($stash); + $fs->remove($base); + $fs->remove($install); + } + + public function test_merge_stash_keeps_user_added_files(): void + { + $io = $this->createStub(IOInterface::class); + $installer = new TestableInstaller($io, null); + + $stash = $this->makeTempDir(); + $base = $this->makeTempDir(); + $install = $this->makeTempDir(); + + // File only in stash (user added it, never in original dist) + file_put_contents($stash.'/user-added.php', 'callMergeStash($stash, $base, $install); + + $this->assertFileExists($install.'/user-added.php'); + $this->assertSame('remove($stash); + $fs->remove($base); + $fs->remove($install); + } + + public function test_merge_stash_respects_upstream_deletions(): void + { + $io = $this->createStub(IOInterface::class); + $installer = new TestableInstaller($io, null); + + $stash = $this->makeTempDir(); + $base = $this->makeTempDir(); + $install = $this->makeTempDir(); + + // File exists in stash and base but upstream removed it (not in install) + file_put_contents($stash.'/deleted-upstream.php', 'old content'); + file_put_contents($base.'/deleted-upstream.php', 'old content'); + // Intentionally NOT present in $install + + $installer->callMergeStash($stash, $base, $install); + + $this->assertFileDoesNotExist($install.'/deleted-upstream.php'); - // Cleanup $fs = new Filesystem; $fs->remove($stash); + $fs->remove($base); $fs->remove($install); } }