diff --git a/bin/pest b/bin/pest index a6f874928..82ec7682f 100755 --- a/bin/pest +++ b/bin/pest @@ -14,6 +14,7 @@ use Pest\TestCaseMethodFilters\NotesTestCaseFilter; use Pest\TestCaseMethodFilters\PrTestCaseFilter; use Pest\TestCaseMethodFilters\FlakyTestCaseFilter; use Pest\TestCaseMethodFilters\TodoTestCaseFilter; +use Pest\Support\Worktree; use Pest\TestSuite; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Output\ConsoleOutput; @@ -142,8 +143,7 @@ use Symfony\Component\Console\Output\ConsoleOutput; $autoloadPath = $localPath; } - // Get $rootPath based on $autoloadPath - $rootPath = dirname($autoloadPath, 2); + $rootPath = Worktree::resolveRoot($autoloadPath); $input = new ArgvInput; diff --git a/bin/worker.php b/bin/worker.php index 7b374785d..40749801e 100644 --- a/bin/worker.php +++ b/bin/worker.php @@ -7,6 +7,7 @@ use Pest\Kernel; use Pest\Plugins\Actions\CallsHandleArguments; use Pest\Support\Container; +use Pest\Support\Worktree; use Pest\TestSuite; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Output\ConsoleOutput; @@ -15,7 +16,7 @@ $bootPest = (static function (): void { $workerArgv = new ArgvInput; - $rootPath = dirname(PHPUNIT_COMPOSER_INSTALL, 2); + $rootPath = Worktree::resolveRoot(PHPUNIT_COMPOSER_INSTALL); $testSuite = TestSuite::getInstance($rootPath, $workerArgv->getParameterOption( '--test-directory', 'tests' @@ -60,7 +61,7 @@ } $container = Container::getInstance(); - $rootPath = dirname(PHPUNIT_COMPOSER_INSTALL, 2); + $rootPath = Worktree::resolveRoot(PHPUNIT_COMPOSER_INSTALL); foreach (Kernel::RESTARTERS as $restarterClass) { $restarter = $container->get($restarterClass); diff --git a/composer.json b/composer.json index a584ea5f7..02ba75ab0 100644 --- a/composer.json +++ b/composer.json @@ -104,6 +104,9 @@ ] }, "extra": { + "branch-alias": { + "dev-feature/worktree-symlinked-vendor": "4.7.x-dev" + }, "pest": { "plugins": [ "Pest\\Mutate\\Plugins\\Mutate", diff --git a/src/Functions.php b/src/Functions.php index cce5566bd..75f9cd10d 100644 --- a/src/Functions.php +++ b/src/Functions.php @@ -312,3 +312,4 @@ function visit(array|string $url, array $options = []): ArrayablePendingAwaitabl return test()->visit($url, $options); } } + diff --git a/src/Support/Worktree.php b/src/Support/Worktree.php new file mode 100644 index 000000000..108f60619 --- /dev/null +++ b/src/Support/Worktree.php @@ -0,0 +1,39 @@ +originalCwd = getcwd(); + + // Create the "real project" directory structure with a vendor autoloader + $this->realProject = sys_get_temp_dir().'/pest-test-real-'.bin2hex(random_bytes(4)); + mkdir($this->realProject.'/vendor', 0777, true); + touch($this->realProject.'/vendor/autoload.php'); + $this->autoloadPath = $this->realProject.'/vendor/autoload.php'; + + // Create the "worktree" directory with a vendor symlink pointing to the real project + $this->worktree = sys_get_temp_dir().'/pest-test-worktree-'.bin2hex(random_bytes(4)); + mkdir($this->worktree, 0777, true); + symlink($this->realProject.'/vendor', $this->worktree.'/vendor'); + + // Create a "different vendor" for the wrong-symlink scenario + $this->differentVendor = sys_get_temp_dir().'/pest-test-different-'.bin2hex(random_bytes(4)); + mkdir($this->differentVendor, 0777, true); + touch($this->differentVendor.'/autoload.php'); +}); + +afterEach(function () { + chdir($this->originalCwd); + + // Recursive removal helpers + $removeDir = function (string $path) use (&$removeDir): void { + if (! is_dir($path) && ! is_link($path)) { + return; + } + foreach (scandir($path) as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $itemPath = $path.'/'.$item; + if (is_link($itemPath) || ! is_dir($itemPath)) { + unlink($itemPath); + } else { + $removeDir($itemPath); + } + } + rmdir($path); + }; + + // Clean up worktree first (symlink and directories) + if (is_link($this->worktree.'/vendor')) { + unlink($this->worktree.'/vendor'); + } + if (is_dir($this->worktree)) { + $removeDir($this->worktree); + } + + // Clean up real project + if (is_file($this->autoloadPath)) { + unlink($this->autoloadPath); + } + if (is_dir($this->realProject.'/vendor')) { + rmdir($this->realProject.'/vendor'); + } + if (is_dir($this->realProject)) { + rmdir($this->realProject); + } + + // Clean up different vendor + if (is_file($this->differentVendor.'/autoload.php')) { + unlink($this->differentVendor.'/autoload.php'); + } + if (is_dir($this->differentVendor)) { + rmdir($this->differentVendor); + } + + unset($this->originalCwd, $this->realProject, $this->autoloadPath, $this->worktree, $this->differentVendor, $removeDir); +}); + +it('returns the autoload parent directory when not inside a worktree', function () { + chdir($this->realProject); + + $root = Worktree::resolveRoot($this->autoloadPath); + + expect($root)->toBe($this->realProject); +}); + +it('returns the worktree root when vendor is a symlink matching the autoload vendor', function () { + chdir($this->worktree); + + $root = Worktree::resolveRoot($this->autoloadPath); + + expect($root)->toBe($this->worktree); +}); + +it('returns the autoload parent directory when vendor symlink points elsewhere', function () { + symlink($this->differentVendor, $this->worktree.'/vendor-wrong'); + rename($this->worktree.'/vendor', $this->worktree.'/vendor-original'); + rename($this->worktree.'/vendor-wrong', $this->worktree.'/vendor'); + + chdir($this->worktree); + + $root = Worktree::resolveRoot($this->autoloadPath); + + expect($root)->toBe($this->realProject); +}); + +it('returns the autoload parent directory when vendor is a real directory, not a symlink', function () { + // Replace the worktree's vendor symlink with a real vendor directory + unlink($this->worktree.'/vendor'); + mkdir($this->worktree.'/vendor', 0777, true); + touch($this->worktree.'/vendor/autoload.php'); + + chdir($this->worktree); + + $root = Worktree::resolveRoot($this->autoloadPath); + + expect($root)->toBe($this->realProject); +}); + +it('sets APP_BASE_PATH in the environment', function () { + chdir($this->realProject); + + Worktree::resolveRoot($this->autoloadPath); + + expect($_ENV['APP_BASE_PATH'])->toBe($this->realProject); +});