A small web app for giving each of your git worktrees its own Apache port, so you can run several branches side by side and look at them all in the browser.
If you work with git worktree on a PHP project, you have probably done this by
hand: pick a port, add a Listen line, write a <VirtualHost> block, fix
APP_URL, restart Apache, and hope you did not typo anything. Doing it once is
fine. Managing six of them, changing ports, and removing old ones is not.
This tool keeps those three things in sync and gives you a button.
- Lists every worktree of your repository and the port it is served on
- Add, edit, stop, start and delete port assignments from the browser
- Rewrites
APP_URLin each worktree's.envwhen its port changes - Validates before it writes: port conflicts, missing directories, bad paths
- Tests the Apache config before restarting, and rolls back automatically if the config is bad, so a mistake cannot leave you without a web server
- Keeps timestamped backups of every config it replaces
It does not create or delete git worktrees. You make those with
git worktree add; this manages how they are served.
- PHP 8.1 or newer with the CLI binary (no extensions beyond the defaults)
- Apache 2.4 with
apachectl - git
sudoaccess for the one-time install
Tested against XAMPP. The Apache layouts for Debian/Ubuntu, RHEL/Fedora and a
source build are auto-detected too, but have had less use — if something is off,
php bin/doctor.php will tell you what it resolved.
git clone https://github.com/AlpetGexha/apache-worktree-manager.git
cd apache-worktree-manager
php bin/doctor.php # check what it detected
sudo ./install.sh # one-time privileged setup
./start.sh # http://localhost:9999install.sh does four things:
- Bakes your resolved paths into the privileged helper and installs it to
/usr/local/bin/worktree-apply, owned by root - Writes a
NOPASSWDsudoers rule for that one script only, for your user - Moves any
Listenlines in the managed port range out of your main Apache config, so a single file owns them - Adds an
Includefor the managed config, if your Apache does not already pick it up automatically
It backs up your main config first and restores it if Apache then fails its own config test.
On first run the app imports whatever worktree vhosts you already have, so an existing hand-rolled setup is adopted rather than wiped.
Start it, open http://localhost:9999, and you get one row per worktree.
- Port rail across the top: every port in range, filled when running, outlined when stopped, grey when free. Click a free one to use it.
- Save port — change a port. Also rewrites that worktree's
.envAPP_URL. - Stop — unbinds the port and removes the vhost, but keeps the entry so the port stays reserved. Start puts it back.
- Delete — removes the service. Your worktree and its files are untouched.
- Preview config — see exactly what will be written before you commit to it.
- Apply — writes the config and restarts Apache.
Create a new worktree builds the command for you rather than running it — worktree creation stays in your hands. Type or pick a branch, tick what the new worktree needs, and copy the result:
cd /var/www/my-project && \
git worktree add .worktrees/checkout-redesign -b feature/checkout-redesign && \
mkdir -p .worktrees/checkout-redesign/bootstrap/cache ... && \
chmod -R 777 .worktrees/checkout-redesign/bootstrap/cache ... && \
cp .env .worktrees/checkout-redesign/.env && \
composer install --working-dir=.worktrees/checkout-redesign && \
php .worktrees/checkout-redesign/artisan key:generate && \
npm --prefix .worktrees/checkout-redesign install && \
npm --prefix .worktrees/checkout-redesign run buildWhether -b appears is worked out for you. Type a name git has never seen
and the command creates the branch; pick an existing one and it checks that
branch out instead. Getting this wrong fails either way — -b on an existing
branch is fatal: a branch named 'x' already exists — so it is derived rather
than left as a checkbox. The line under the fields tells you which case you are
in.
The branch list offers every local branch that is not already checked out, since
git will not put one branch in two worktrees; naming one that is gets you a
clear error instead of a command that cannot work. The folder name is derived
from the branch unless you override it. Steps are chained with &&, so a
failure stops the rest.
Create writable dirs covers a trap specific to worktrees. Directories a
project ignores outright — Laravel's bootstrap/cache and
storage/framework/cache/data — are not in the index, so git worktree add
never creates them. composer install then fails on its own post-autoload-dump
hook with "The bootstrap/cache directory must be present and writable". The
step runs before composer for exactly that reason, and chmods the directories
because Apache usually runs as its own account. Set writable_dirs and
writable_mode for a different framework, or writable_dirs => [] to skip it.
Copying .env keeps the parent's database — change DB_DATABASE afterwards if
you want the worktree isolated. APP_URL you can leave alone: assigning a port
here rewrites it.
Run the command, and the new worktree appears in the list above ready for a
port. Set worktree_dir in config.php to put worktrees somewhere other than
.worktrees/.
Nothing reaches Apache until you press Apply. Until then edits are saved locally and the page tells you changes are not live yet.
Everything is auto-detected. To override anything:
cp config.example.php config.phpconfig.php is gitignored, so your machine's paths stay out of the repository.
Every key can also be set as an environment variable with a WM_ prefix
(WM_PORT_START=9000). Precedence, lowest to highest:
built-in defaults -> auto-detection -> config.php -> environment
| Key | Default | What it is |
|---|---|---|
repo_root |
nearest git repo | Repository whose worktrees are managed |
apachectl |
detected | Path to the apachectl binary |
managed_conf |
detected | The config file this tool owns and rewrites |
main_conf |
detected | Main Apache config, for Listen cleanup |
backup_dir |
next to managed_conf |
Where config backups are kept |
helper_path |
/usr/local/bin/worktree-apply |
Where the helper is installed |
port_start / port_end |
8081 / 8199 |
Range offered to new services |
docroot_suffix |
public |
Subdirectory Apache serves (web, or '') |
server_name_pattern |
worktree-%s.local |
ServerName; %s is the slug |
worktree_dir |
.worktrees |
Where the create command puts new worktrees |
writable_dirs |
Laravel's set | Dirs a fresh worktree needs but git ignores |
writable_mode |
777 |
Mode applied to those dirs |
bind |
0.0.0.0 |
Interface the app listens on |
port |
9999 |
Port the app listens on |
auth_token |
none | Shared secret; see Security |
Run php bin/doctor.php any time to see the resolved values, which source they
came from, and whether anything is broken.
Set docroot_suffix to whatever your framework serves from — web for Drupal,
public for Laravel and Symfony, '' to serve the worktree root directly. The
.env APP_URL rewrite is skipped automatically when a worktree has no .env.
Read this before exposing the port. By default the app binds 0.0.0.0,
which means anyone who can reach your machine on port 9999 can change your
Apache config and restart it. That is fine on a laptop behind a firewall and a
bad idea on a shared network.
Two ways to lock it down, in config.php:
'bind' => '127.0.0.1', // local only
'auth_token' => 'some-long-random', // or require a shared secretWith a token set, open http://host:9999/?token=some-long-random once — the
browser remembers it for the session and every API call carries it.
The web app never runs as root. All privileged work is one script,
/usr/local/bin/worktree-apply, which:
- takes no arguments — the only thing crossing the privilege boundary is "go"
- has its paths baked in at install time, not read from a config file the app could write. If it read them at runtime, anyone able to reach the app could make root copy an arbitrary file to an arbitrary location.
- is owned by root and not writable by the app's user
- is the sole entry in a
NOPASSWDsudoers rule scoped to your user
A DocumentRoot can only ever point at a directory that git worktree list
reports for the configured repository. Arbitrary paths are rejected, which is
also what stops the .env rewrite from touching files outside your worktrees.
Names and paths containing quotes, backslashes or control characters are rejected, so nothing can inject directives into a config that root then loads.
Three things have to agree for a worktree preview to work, and normally nothing keeps them in sync:
Listen 8082— so Apache binds the port- A
<VirtualHost *:8082>— so Apache knows what to serve APP_URLin that worktree's.env— so generated links are right
This tool keeps services.json as the source of truth and regenerates the
whole managed config from it on every apply, Listen lines included. There is
no patching of a live config with regexes, and no second place for the Listen
lines to drift out of sync — which is why install.sh moves them out of your
main config.
Apply is: write staged config → back up the current one → install it → run
apachectl -t → restart on pass, restore the backup on fail. A hard restart
is used rather than a graceful reload, because Apache does not re-read Listen
directives on a reload.
| Path | What it is |
|---|---|
server.php |
Router and JSON API. Front controller for php -S |
lib.php |
Config resolution, generation, validation. Pure, tested |
index.html |
The UI. Vanilla JS, no build step, no dependencies |
bin/worktree-apply.template |
The privileged helper, before path substitution |
bin/doctor.php |
Prints resolved config and checks the environment |
bin/dump-config.php |
Config as shell variables, for the install scripts |
install.sh / uninstall.sh |
Privileged setup and teardown |
start.sh |
Launcher |
tests.php |
Assertions over lib.php |
services.json |
Local state. Gitignored, rebuilt on first run |
php tests.phpNo framework and no dependencies — the suite is plain assertions over the pure
functions in lib.php, covering config resolution, config generation, the
.env rewrite, port allocation, validation and the injection guards. Exits
non-zero on failure, so it drops straight into CI.
"Apply needs one-time setup" — install.sh has not been run. The banner has
the exact command.
"sudo: a password is required" — the helper is installed, but the sudoers
rule names a different account than the one running the app. install.sh grants
it to whoever invoked it, which is not always who starts the server. Either start
the app as that user, or grant the current one:
sudo ./install.sh --user "$(id -un)" # or --user alice,deploy for severalNever put a sudo password in a config file to work around this. The rule exists precisely so no password is needed, and a plaintext root password readable by the web app would be worth more than everything this tool guards.
Apply fails and says it rolled back — Apache rejected the generated config and your previous one was restored, so nothing is broken. The helper's output includes Apache's own error message.
A port shows as "down" after applying — Apache bound the port but the app
behind it is erroring. Check that worktree's own logs; the vhost's error log is
worktree-<slug>-error_log in your Apache log directory.
A deleted worktree still shows as available — deleting the folder by hand
leaves git's entry behind, flagged prunable. Worktrees in that state are no
longer offered, and a banner gives you the command:
git worktree pruneUsing git worktree remove <path> instead of rm -rf avoids this. Note that a
prunable entry still holds its branch, so the branch will not reappear in the
create picker until you prune.
A worktree does not appear in the dropdown — it only lists worktrees that
git worktree list reports for repo_root, excluding the main checkout. Run
php bin/doctor.php to see what git reports.
"That path is not a worktree of this repository" — deliberate. Only real
worktrees can be served. Check repo_root is the repository you think it is.
Changes are not showing up — nothing reaches Apache until you press Apply.
sudo ./uninstall.shRemoves the helper, the sudoers rule and the managed config, then restarts Apache. Your worktrees, their files and this directory are left alone.
Not yet chosen — add a LICENSE file if you want others to be able to use this.