Skip to content

Git worktrees

Use a git worktree when more than one person or agent session is working in this repo at once. Each linked worktree has its own checked-out files, branch, HEAD, and index, while the repository objects and local branch refs remain shared. That isolates uncommitted edits and staging, but deleting or moving a shared branch ref still affects every checkout.

Worktrees live outside the checkout, in one .capell-wt root shared by the App, Core and companion-package repositories. Set CAPELL_WORKTREE_ROOT to that root (the App’s ./capell worktree reads the same variable); otherwise use ../.capell-wt. When the App and Core checkouts sit in different parent directories, the fallback produces a second root whose orphaned worktrees nobody cleans up, so set the variable. Never create one inside the repository — an in-tree worktree is deleted by git clean -fdx run in the primary checkout, and every tree-wide scan then walks two copies of the source.

Terminal window
git worktree add "${CAPELL_WORKTREE_ROOT:-../.capell-wt}/core-my-feature" -b feature/my-feature

First: are you running PHP on the host, or in Docker?

Section titled “First: are you running PHP on the host, or in Docker?”

This decides how you provision vendor/, and getting it wrong costs you a session.

scripts/init-worktree.sh builds vendor/ out of host-absolute symlinks into the primary checkout (/Users/... on macOS). The container never sees those paths: docker-compose.yml bind-mounts the worktree at /home/capell/current, and it does not mount the primary checkout at all. Every symlinked package therefore dangles inside the container.

PHP does not report that as a missing package. It reports a fatal on the first require of a dangling path, before a single test runs:

require(/Users/.../vendor/symfony/deprecation-contracts/function.php): Failed to open stream

which reads like a corrupt install rather than a wrong-tree layout. That misdiagnosis cost one agent session 6 of its 21 commands.

Do this instead, in the worktree:

Terminal window
./capell up
./capell composer install

./capell derives a per-worktree Compose project from the directory name, so this stack is isolated from the primary checkout’s. The install is a real, self-contained vendor/ that resolves inside the container, and with a warm Composer cache it is not slow — a measured cold-worktree run on 2026-08-19 took 41 seconds, plus a few seconds for ./capell up.

scripts/init-worktree.sh now detects this repo’s Docker harness and refuses to run, naming that remedy, rather than leaving a vendor/ that only works on the host. Pass --host-only to override it if you really do run PHP on the host and never through ./capell.

Remember to ./capell down when the worktree is idle. Parallel worktree stacks compound.

Host-only. See Docker worktrees first if you run tooling through ./capell.

A fresh worktree has no vendor/, and a full composer install costs several minutes and about 1.5 GB. Run this instead:

Terminal window
bash scripts/init-worktree.sh --host-only

It completes in under ten seconds, uses about 75 MB, verifies itself, and refuses to leave a broken vendor/ behind. Pass --force to rebuild an existing one.

This is the part that matters, because the failure is silent.

Composer’s generated autoloader computes $baseDir from __DIR__ inside vendor/composer/, and PHP resolves symlinks when evaluating __DIR__. If vendor/ — or just vendor/composer/ — is a symlink into the primary checkout, $baseDir becomes the primary checkout. Every Capell\* class then loads from the primary tree. Your worktree’s edits are invisible, and the suite passes while exercising completely different code. On a shared checkout it is worse: you are testing another session’s uncommitted work.

scripts/init-worktree.sh avoids this by keeping the parts that determine $baseDir real, and sharing only what is safe to share:

PathTreatmentWhy
vendor/composer/, vendor/autoload.phpreal copy$baseDir must resolve to this worktree
vendor/bin/real copybinary proxies must point at this worktree’s autoloader
vendor/<vendor>/<package>/symlinkthird-party code, identical in both trees
pestphp/pest, phpunit/phpunit, laravel/pint, phpstan/phpstan, rector/rector, brianium/paratestreal copytheir bin scripts walk __DIR__ upward to find an autoloader, and would find the primary one

Known limitation — verify before trusting a full-suite run

Section titled “Known limitation — verify before trusting a full-suite run”

The provisioner regenerates the copied autoloader from the worktree’s manifest, including development source mappings. The primary vendor metadata may predate a new fixture namespace or package source path. Copying it without regeneration can pass a single Core-class probe while leaving those newer classes unavailable. Autoload regeneration disables Composer scripts and plugins and writes only the owned loader; it does not install or update shared dependencies.

The script fixes the common failure, not every one. Because third-party packages are symlinked, any code that walks upward from inside vendor/ — dirname(__DIR__, N), or a Composer ClassLoader re-registered at runtime — resolves into the primary checkout, and can pull Capell\* classes from there.

Most suites are unaffected. The core Unit/Support suite is not: running it before another suite has been observed to load Capell\Admin\* from the primary tree, so the later tests silently exercise the wrong code. Confirmed 2026-07-23 by printing ReflectionClass::getFileName() mid-run.

Practical rule: use the script for fast, targeted runs, and check that the classes you changed resolve inside the worktree before believing a result. If you need an authoritative full-suite run, do a real composer install in the worktree.

Pest’s architecture presets also follow symlinks before excluding the checkout’s vendor/ directory, so external dependency roots can be mistaken for application source. This can fatally load optional integrations such as Symfony Serializer’s SerializerPass without its optional PriorityTaggedServiceTrait. Use an independent vendor tree for authoritative architecture gates. A process-only bootstrap restoring the intended vendor exclusion is a diagnostic workaround, not clean-vendor evidence.

Do not run composer test in a hybrid worktree. Its clear step runs testbench package:purge-skeleton and removes vendor/orchestra/testbench-core/laravel/{vendor,database/migrations}; testbench-core is a symlink there, so the removal mutates the primary checkout’s skeleton. Run vendor/bin/pest <paths> instead. scripts/check-vendor-integrity.php (run first by clear) refuses a full suite on a hybrid vendor for the same reason.

Verify before trusting a test result:

Terminal window
php -r 'require "vendor/autoload.php"; echo (new ReflectionClass("Capell\Core\Enums\ImageSourceType"))->getFileName(), PHP_EOL;'

The path must be inside your worktree. If it points at the primary checkout, delete vendor/ and start again — every green test you have seen is meaningless.

Never mutate dependencies from a hybrid worktree

Section titled “Never mutate dependencies from a hybrid worktree”

Composer scripts such as composer test:unit are safe. Dependency-mutating commands such as install, require, update, and remove are not: the hybrid vendor/ contains symlinks into the primary checkout. Manage dependencies in the primary checkout, then re-run scripts/init-worktree.sh --host-only --force in each owned host worktree. The force option replaces that worktree’s vendor layout.

scripts/init-worktree.sh gives the worktree its own node_modules/ as well as its own vendor/. It needs one: composer preflight runs prettier and eslint, so without node_modules/ the whole preflight exits before a single PHP stage runs, and the error names npm rather than the real cause.

Unlike vendor/, this is not shared. The script clones the directory copy-on-write, which on APFS shares the underlying blocks with the primary checkout until something writes — about a second, and almost no disk. On a filesystem without clone support it falls back to npm ci. An existing node_modules/ is left alone.

The warning above about dependency-mutating Composer commands does not apply to npm. Because node_modules/ is a real directory rather than a symlink, npm install and npm ci in a worktree cannot reach the primary checkout.

That isolation is the reason for the clone. npm ci deletes node_modules/ before it installs, so had the directory been symlinked, one npm ci in one worktree would have wiped the primary checkout’s copy out from under every other worktree and session using it.

In a Docker worktree, run them through the wrapper, which supplies the testing environment the package database guard requires:

Terminal window
./capell pest packages/frontend/tests/Feature/SomeTest.php --configuration=phpunit.xml --compact

./capell exec vendor/bin/pest ... skips that environment and fails with Refusing to run Capell package Pest tests against database [capell_4].

Before the first test or preflight in a fresh worktree, install its dependencies:

Terminal window
./capell up
./capell composer install

The wrapper checks for vendor/bin/pest before test and preflight commands and fails with this bootstrap instruction when it is absent. Never symlink vendor/ from another checkout: Composer’s autoloader must resolve Capell classes from the current worktree. Use bash scripts/init-worktree.sh --host-only only when all tooling will run on the host.

On the host it is identical to the primary checkout, but note that this repo’s tooling needs an explicit memory limit — PHP’s 128 MB default causes a fatal that looks like a broken setup:

Terminal window
composer test:unit

For a narrower run:

Terminal window
vendor/bin/pest --compact --configuration=phpunit.xml packages/core/tests/Unit

Do not pass -d memory_limit to Pest: phpunit.xml sets the limit through <ini>, which overrides the command-line flag in every process (see CI). PHPStan has no such file, so it still needs the flag:

Terminal window
php -d memory_limit=2G vendor/bin/phpstan analyse --no-progress packages/core/src

Each worktree has its own index. Path-scoped commits are still useful when more than one task is active in the same worktree:

  • Commit by pathspec — git commit -F - -- path/one path/two. Plain git add X && git commit commits the whole index, including unrelated files staged in that checkout.
  • For a new file, git add -N <path> first, or the pathspec commit errors.
  • Verify immediately with git show --stat: the file count must match your pathspec.
  • Commit each slice as its edits land rather than batching until the end. Isolation protects you from other sessions; frequent commits protect you from everything else.

Several sessions may share one checkout, and the working tree and index are then shared state. Another session’s git checkout, reset --hard or merge silently reverts uncommitted edits, so:

  • Run git branch --show-current immediately before every commit. The checkout can be switched to another feature branch mid-session; note which branch a commit landed on.
  • Commit each slice as soon as its edits land, before verification. Batch verification only inside your own worktree.
  • The pre-commit framework stashes and restores unstaged files around each commit. A concurrent write inside that window can be replayed over, so “my edits vanished” is an expected failure mode here: record the commit SHAs rather than debugging it.
  • A commit that sweeps in another session’s staged files can silently revert their work (the staged copy may be older than their working tree). Undo your own top commit with git reset --soft HEAD~1, then re-commit by pathspec.

Remove scratch worktrees when they have served their purpose, and delete a merged branch locally and on the remote in the same pass. Only remove what you created: a worktree whose HEAD matches no remote branch (git branch -r --contains <sha>) holds unpushed work, and uncommitted files count too.

Auditing branches in a squash-merge repository

Section titled “Auditing branches in a squash-merge repository”

Pull requests are squash-merged, so git branch --merged and git merge-base --is-ancestor report merged branches as unmerged: branch commits never become ancestors of main. Instead:

  1. No-op merge test: git merge-tree --write-tree origin/main <branch>. If the resulting tree equals git rev-parse origin/main^{tree}, the branch is fully merged.
  2. Direction test: if merge-tree reports conflicts, check git diff origin/main <branch> -- <file>. If the branch’s changes appear as removals, the branch is behind main, not ahead.
  3. Feature-presence test: confirm the feature exists on main (list the directory, search for the identifier) instead of trusting history.
  4. Ground truth for “was a PR merged”: gh pr list --state all --json headRefName,state.

To rebase a stale branch across the squash gap, use the end-state rule. For each conflicting file, if git diff origin/main <branch-tip> -- <file> is empty, taking main’s side at every intermediate rebase step cannot change the final tree, so it is safe to auto-resolve. Only files with a non-empty end-state diff need judgement; for those, search both trees for the APIs each side references (git grep): the branch is usually the stale side.