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 and its own node_modules/ directory. Running composer install and npm install (or equivalent) in every worktree is slow and wastes disk space — a typical project's combined vendor/ and node_modules/ can easily exceed 500 MB, and often runs much higher.

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

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

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

Note on scope: This PR does not implement or advocate for symlinking vendor or node_modules directories. It does not create worktrees, set up symlinks, or change how worktrees are configured. It is a pure compatibility fix: if a project happens to be configured with symlinked directories (for whatever reason), Pest should still resolve its root path correctly.

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).