Skip to content
Mike Scott edited this page Jul 12, 2026 · 5 revisions

Git worktree support with symlinked vendor directories

Background

Git worktrees allow you to check out multiple branches simultaneously in separate working directories, all sharing a single .git directory. They are extremely useful when you need to work on several features or bugs at once without stashing or cloning the repository multiple times.

However, each worktree needs its own vendor/ directory. Running composer install in every worktree is slow and wastes disk space — a typical project's vendor/ can be hundreds of megabytes or gigabytes.

The common workaround: symlink vendor from the main worktree into your feature worktrees:

# In a feature worktree
ln -s ../main/vendor vendor

This makes worktree creation and removal near-instant, with negligible disk overhead.

The problem

Pest (and PHPUnit) determine the project root directory from the location of Composer's autoloader. In bin/pest:

$vendorPath = dirname(__DIR__, 4).'/vendor/autoload.php';

__DIR__ and __FILE__ in PHP resolve symlinks. When vendor is a symlink, __DIR__ in bin/pest (which lives at vendor/pestphp/pest/bin/pest) follows that symlink to the main worktree's vendor directory. The resolved path looks like:

/main-worktree/vendor/pestphp/pest/bin

So dirname(__DIR__, 4) gives /main-worktree — not the feature worktree where the user actually is. This means Pest looks for Pest.php, tests/, and phpunit.xml in the wrong place.

The fix

The fix lives in src/Support/Worktree.php. Before using dirname($autoloadPath, 2) as the project root, we check:

  1. Is the current working directory's vendor a symlink?
  2. Does that symlink realpath() to the same directory as the parent of the autoload file?

If both conditions are true, we're inside a worktree with a symlinked vendor, and the CWD is the correct project root.

$cwd = getcwd();
if ($cwd !== false
    && is_link($cwd.'/vendor')
    && realpath($cwd.'/vendor') === dirname($autoloadPath, 1)) {
    $rootPath = $cwd;
}

This is used in both entry points (bin/pest and bin/worker.php) via the shared class Pest\Support\Worktree::resolveRoot().

Files changed

File Change
src/Support/Worktree.php New internal class with resolveRoot()
bin/pest Replaced inline root resolution with Worktree::resolveRoot()
bin/worker.php Replaced local resolveWorktreeRoot() function with Worktree::resolveRoot()
src/Functions.php (No change — the function was briefly placed here, then moved)
tests/Unit/Worktree.php Unit tests for all four scenarios

Testing

The unit tests in tests/Unit/Worktree.php create real temp directory structures with controlled symlinks to verify behaviour in all four scenarios:

  • Normal project — no symlink
  • Worktree with matching symlink — the fix scenario
  • Worktree with symlink pointing elsewhere — should not override
  • Worktree with real vendor directory — should not override

Upstream PR

See the pull request at pestphp/pest#... (link TBD).

Clone this wiki locally