From c8eac85a434cf876ee07ea6c7bad2d8e29f875f8 Mon Sep 17 00:00:00 2001 From: Mike Scott Date: Mon, 6 Jul 2026 23:00:57 +0100 Subject: [PATCH 1/7] Detect symlinked vendor and use CWD as root path for git worktree support --- bin/pest | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bin/pest b/bin/pest index a6f874928..a59daa4ee 100755 --- a/bin/pest +++ b/bin/pest @@ -145,6 +145,17 @@ use Symfony\Component\Console\Output\ConsoleOutput; // Get $rootPath based on $autoloadPath $rootPath = dirname($autoloadPath, 2); + // Worktree / symlinked vendor support + // If the CWD has a vendor symlink pointing to the same + // resolved vendor directory, use CWD as root so worktree + // tests find their own Pest.php, tests/, and phpunit.xml. + $cwd = getcwd(); + if ($cwd !== false + && is_link($cwd . '/vendor') + && realpath($cwd . '/vendor') === dirname($autoloadPath, 1)) { + $rootPath = $cwd; + } + $input = new ArgvInput; $testSuite = TestSuite::getInstance( From 8d9daf117e0895760f004a0be487b766fba7288f Mon Sep 17 00:00:00 2001 From: Mike Scott Date: Sat, 11 Jul 2026 10:32:25 +0100 Subject: [PATCH 2/7] Extract worktree root detection into shared resolveWorktreeRoot() helper Applies the same symlinked-vendor detection logic from bin/pest to bin/worker.php, but extracted into a single reusable function instead of duplicating the inline check at two call sites. --- bin/worker.php | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/bin/worker.php b/bin/worker.php index 7b374785d..8e07f9575 100644 --- a/bin/worker.php +++ b/bin/worker.php @@ -12,10 +12,28 @@ use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\OutputInterface; +/** + * Resolve the project root path, accounting for git worktrees + * where vendor is a symlink to another worktree's vendor directory. + */ +function resolveWorktreeRoot(string $autoloadPath): string +{ + $rootPath = dirname($autoloadPath, 2); + + $cwd = getcwd(); + if ($cwd !== false + && is_link($cwd . '/vendor') + && realpath($cwd . '/vendor') === dirname($autoloadPath, 1)) { + $rootPath = $cwd; + } + + return $rootPath; +} + $bootPest = (static function (): void { $workerArgv = new ArgvInput; - $rootPath = dirname(PHPUNIT_COMPOSER_INSTALL, 2); + $rootPath = resolveWorktreeRoot(PHPUNIT_COMPOSER_INSTALL); $testSuite = TestSuite::getInstance($rootPath, $workerArgv->getParameterOption( '--test-directory', 'tests' @@ -60,7 +78,7 @@ } $container = Container::getInstance(); - $rootPath = dirname(PHPUNIT_COMPOSER_INSTALL, 2); + $rootPath = resolveWorktreeRoot(PHPUNIT_COMPOSER_INSTALL); foreach (Kernel::RESTARTERS as $restarterClass) { $restarter = $container->get($restarterClass); From 701b3852ebbdbf9fe50c1393bc7983c8100dc583 Mon Sep 17 00:00:00 2001 From: Mike Scott Date: Sat, 11 Jul 2026 10:49:18 +0100 Subject: [PATCH 3/7] Set APP_BASE_PATH env var after root path resolution When the worktree detection corrects the root path for symlinked vendor directories, also set ['APP_BASE_PATH'] so that Laravel's inferBasePath() picks up the correct worktree path instead of falling back to the autoloader path (which points to the symlinked origin worktree). This eliminates the need for a per-worktree APP_BASE_PATH override in phpunit.xml. --- bin/pest | 2 ++ bin/worker.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/bin/pest b/bin/pest index a59daa4ee..1fd73705c 100755 --- a/bin/pest +++ b/bin/pest @@ -156,6 +156,8 @@ use Symfony\Component\Console\Output\ConsoleOutput; $rootPath = $cwd; } + $_ENV['APP_BASE_PATH'] = $rootPath; + $input = new ArgvInput; $testSuite = TestSuite::getInstance( diff --git a/bin/worker.php b/bin/worker.php index 8e07f9575..0bef411d4 100644 --- a/bin/worker.php +++ b/bin/worker.php @@ -27,6 +27,8 @@ function resolveWorktreeRoot(string $autoloadPath): string $rootPath = $cwd; } + $_ENV['APP_BASE_PATH'] = $rootPath; + return $rootPath; } From be9ef13ea7dea6e5927842c06d51e8eb03424ea2 Mon Sep 17 00:00:00 2001 From: Mike Scott Date: Sat, 11 Jul 2026 11:22:34 +0100 Subject: [PATCH 4/7] Add branch alias for feature/worktree-symlinked-vendor so it satisfies ^4.x constraints --- composer.json | 3 +++ 1 file changed, 3 insertions(+) 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", From 7aea3b96e33f47b09858c4df6348d5884fabe1c6 Mon Sep 17 00:00:00 2001 From: Mike Scott Date: Sat, 11 Jul 2026 19:50:01 +0100 Subject: [PATCH 5/7] Extract duplicated worktree root resolution into Pest\Support\Worktree The same block of code for resolving the project root path when vendor is a symlinked directory (git worktree support) existed in both bin/pest (inline) and bin/worker.php (as a local function). Extracted it to a dedicated internal class so there is one definition shared by both entry points. --- bin/pest | 16 +--------------- bin/worker.php | 24 ++---------------------- src/Functions.php | 1 + src/Support/Worktree.php | 39 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 37 deletions(-) create mode 100644 src/Support/Worktree.php diff --git a/bin/pest b/bin/pest index 1fd73705c..73bdd1e21 100755 --- a/bin/pest +++ b/bin/pest @@ -142,21 +142,7 @@ use Symfony\Component\Console\Output\ConsoleOutput; $autoloadPath = $localPath; } - // Get $rootPath based on $autoloadPath - $rootPath = dirname($autoloadPath, 2); - - // Worktree / symlinked vendor support - // If the CWD has a vendor symlink pointing to the same - // resolved vendor directory, use CWD as root so worktree - // tests find their own Pest.php, tests/, and phpunit.xml. - $cwd = getcwd(); - if ($cwd !== false - && is_link($cwd . '/vendor') - && realpath($cwd . '/vendor') === dirname($autoloadPath, 1)) { - $rootPath = $cwd; - } - - $_ENV['APP_BASE_PATH'] = $rootPath; + $rootPath = \Pest\Support\Worktree::resolveRoot($autoloadPath); $input = new ArgvInput; diff --git a/bin/worker.php b/bin/worker.php index 0bef411d4..cdddfb304 100644 --- a/bin/worker.php +++ b/bin/worker.php @@ -12,30 +12,10 @@ use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\OutputInterface; -/** - * Resolve the project root path, accounting for git worktrees - * where vendor is a symlink to another worktree's vendor directory. - */ -function resolveWorktreeRoot(string $autoloadPath): string -{ - $rootPath = dirname($autoloadPath, 2); - - $cwd = getcwd(); - if ($cwd !== false - && is_link($cwd . '/vendor') - && realpath($cwd . '/vendor') === dirname($autoloadPath, 1)) { - $rootPath = $cwd; - } - - $_ENV['APP_BASE_PATH'] = $rootPath; - - return $rootPath; -} - $bootPest = (static function (): void { $workerArgv = new ArgvInput; - $rootPath = resolveWorktreeRoot(PHPUNIT_COMPOSER_INSTALL); + $rootPath = \Pest\Support\Worktree::resolveRoot(PHPUNIT_COMPOSER_INSTALL); $testSuite = TestSuite::getInstance($rootPath, $workerArgv->getParameterOption( '--test-directory', 'tests' @@ -80,7 +60,7 @@ function resolveWorktreeRoot(string $autoloadPath): string } $container = Container::getInstance(); - $rootPath = resolveWorktreeRoot(PHPUNIT_COMPOSER_INSTALL); + $rootPath = \Pest\Support\Worktree::resolveRoot(PHPUNIT_COMPOSER_INSTALL); foreach (Kernel::RESTARTERS as $restarterClass) { $restarter = $container->get($restarterClass); 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 @@ + Date: Sat, 11 Jul 2026 19:52:21 +0100 Subject: [PATCH 6/7] Use import instead of FQN for Worktree class --- bin/pest | 3 ++- bin/worker.php | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/bin/pest b/bin/pest index 73bdd1e21..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,7 +143,7 @@ use Symfony\Component\Console\Output\ConsoleOutput; $autoloadPath = $localPath; } - $rootPath = \Pest\Support\Worktree::resolveRoot($autoloadPath); + $rootPath = Worktree::resolveRoot($autoloadPath); $input = new ArgvInput; diff --git a/bin/worker.php b/bin/worker.php index cdddfb304..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 = \Pest\Support\Worktree::resolveRoot(PHPUNIT_COMPOSER_INSTALL); + $rootPath = Worktree::resolveRoot(PHPUNIT_COMPOSER_INSTALL); $testSuite = TestSuite::getInstance($rootPath, $workerArgv->getParameterOption( '--test-directory', 'tests' @@ -60,7 +61,7 @@ } $container = Container::getInstance(); - $rootPath = \Pest\Support\Worktree::resolveRoot(PHPUNIT_COMPOSER_INSTALL); + $rootPath = Worktree::resolveRoot(PHPUNIT_COMPOSER_INSTALL); foreach (Kernel::RESTARTERS as $restarterClass) { $restarter = $container->get($restarterClass); From d29a19b316c530753cb65f1962835ebd3a81b6fe Mon Sep 17 00:00:00 2001 From: Mike Scott Date: Sun, 12 Jul 2026 09:32:46 +0100 Subject: [PATCH 7/7] Add unit tests for Pest\Support\Worktree::resolveRoot Covers four scenarios for worktree root resolution: - Normal project (no symlink) - Worktree with matching vendor symlink - Worktree with vendor symlink pointing elsewhere - Worktree with real vendor directory (not a symlink) Also verifies the ['APP_BASE_PATH'] side effect. --- tests/Unit/Worktree.php | 124 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 tests/Unit/Worktree.php diff --git a/tests/Unit/Worktree.php b/tests/Unit/Worktree.php new file mode 100644 index 000000000..561266388 --- /dev/null +++ b/tests/Unit/Worktree.php @@ -0,0 +1,124 @@ +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); +});