Skip to content

Git worktrees

A git worktree is a second checkout of the same repository, on another branch, in another directory. The plugin makes it a stack of its own: its own containers, its own volumes, its own domains — so you can review a pull request, or reproduce a bug on a release branch, without stopping what you were working on.

Nothing has to be configured. Add a worktree and its stack is already isolated:

$ castor worktree:create bug-4242
$ cd ../worktrees/myproject/bug-4242 && castor docker:up --build

What a worktree gets of its own

Everything the plugin names is derived from two values, and a linked worktree gets its own:

Main checkout Worktree bug-4242
project_name myproject myproject-bug-4242
root_domain myproject.test bug-4242.myproject.test

From there, the containers, the network, the named volumes, the images of a shared builder and the TCP forwarders of <service>:expose all follow.

The domains follow too, including the ones you spell out yourself: a service declaring withDomain('app.myproject.test') is served on https://app.bug-4242.myproject.test in the worktree. The worktree's label is inserted right before the root domain, so a domain already derived from root_domain lands on the very same name — the rewrite is idempotent.

A domain that is not under the root domain is left alone, because nothing says what it should become. castor docker:about reports it, along with any host port a service publishes with port(): those belong to the machine, and only one checkout at a time can have them.

The caches are shared

The shared home directory holds what every service of the project caches — Composer, Cargo, npm. A worktree mounts the .home of the main checkout, by absolute path, so the caches are filled once for the whole repository instead of once per branch: a new worktree does not pay for a cold build.

It is created from the worktree when the main checkout never ran castor, so a fresh clone works either way round. Set worktree_shared_home to false to give each checkout a .home of its own, and a service pointing withSharedHomeDirectory() at an absolute path already keeps whatever it names.

Managing the checkouts

$ castor worktree:list                       # every checkout, its branch, its stack and its URL
$ castor worktree:create bug-4242            # create it, on a branch of the same name
$ castor worktree:create bug-4242 --start    # …and build and start its stack
$ castor worktree:delete bug-4242            # destroy its stack and remove it, keeping the branch

worktree:create takes the name as typed for the branch and slugifies it for everything else, so castor worktree:create feat/new-thing checks out feat/new-thing in a feat-new-thing worktree. --branch picks another one, and --from says where a branch that does not exist yet starts.

--start runs your project's own start task when it has one, and castor docker:up --build otherwise. The worktree_start_task context data names another task to run instead, 'demo:start' for instance.

worktree:delete asks before throwing away uncommitted changes or commits that were never pushed; --force skips the questions. The branch is always kept.

Starting from the data of a checkout

A worktree starts with empty databases: its volumes are its own. --copy-data fills them with the databases of the checkout you run it from instead:

$ castor worktree:create bug-4242 --copy-data --start

That is what makes a worktree the place for what you would not risk on your own data — a migration that may go wrong, a test that wipes a table: the worktree works on a copy, and throwing it away with worktree:delete is the way back.

The copy is taken from wherever the task runs, so a worktree can start from another one: castor --worktree=bug-4242 worktree:create bug-4243 --copy-data.

Each database goes through a dump: the checkout it is copied from keeps running, and the worktree may well run another version of the server — a branch upgrading Postgres is the very kind a worktree is for. The dump is restored by the worktree itself, with the castor.php of its branch, so it lands in whatever that branch declares. A database the checkout never started has nothing to copy, and the worktree starts with an empty one.

Where the checkouts live

<parent of the main checkout>/worktrees/<repository>/<name> by default, which keeps them out of the main checkout. The worktree_directory context variable overrides it:

return new Context([
    // "<parent>/branches/bug-4242"
    'worktree_directory' => 'branches',
    // Or, for the layout some editors create, "<parent>/worktrees/bug-4242/myproject"
    'worktree_directory' => 'worktrees/{name}/myproject',
]);

A relative path is resolved against the parent of the main checkout, and {name} is where the name of the worktree goes — appended when you leave it out.

Running a task somewhere else

Every task takes a --worktree, and castor re-runs itself in that checkout — so the task acts on its stack, with its code and its dependencies:

$ castor --worktree=bug-4242 docker:logs app
$ castor --worktree=main docker:about

Beware that a bare docker compose command run from a worktree targets the main stack: compose.yaml is shared, name: included, and only castor overrides it (with COMPOSE_PROJECT_NAME). Go through castor docker:*, or pass -p myproject-bug-4242 yourself.

Turning the isolation off

A checkout that should act on the main stack rather than on one of its own sets:

return new Context([
    'worktree_isolation' => false,
]);

The plugin then leaves project_name and root_domain exactly as you declared them.

A project that would rather derive the names itself can also just do it: the plugin only suffixes a project name that does not end with the worktree's name, and only prefixes a root domain that does not start with it.

A second clone is not a worktree

Detection reads the .git of the checkout: a linked worktree has a .git file pointing into .git/worktrees/, where a clone has a .git directory. A second clone therefore shares the stack of the first, and has to be told apart by hand — see running two checkouts side by side.