Skip to content

Commit 4f658da

Browse files
committed
feat: add git worktree isolation via port shifting
1 parent 4ba23b9 commit 4f658da

7 files changed

Lines changed: 146 additions & 5 deletions

File tree

‎.castor/context.php‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,24 @@
88

99
use function Castor\log;
1010

11+
/** @return array<string, array{env: string, default: int, label: string}> */
12+
function get_port_specs(): array
13+
{
14+
return [
15+
'http' => ['env' => 'PROJECT_HTTP_PORT', 'default' => 80, 'label' => 'HTTP'],
16+
'https' => ['env' => 'PROJECT_HTTPS_PORT', 'default' => 443, 'label' => 'HTTPS'],
17+
'admin' => ['env' => 'PROJECT_ADMIN_PORT', 'default' => 8080, 'label' => 'Traefik admin'],
18+
];
19+
}
20+
1121
#[AsContext(default: true)]
1222
function create_default_context(): Context
1323
{
1424
$data = create_default_variables() + [
1525
'project_name' => 'app',
1626
'root_domain' => 'app.test',
1727
'extra_domains' => [],
28+
'ports' => get_port_specs(),
1829
'php_version' => '8.5',
1930
'docker_compose_files' => [
2031
'docker-compose.yml',
@@ -49,6 +60,14 @@ function create_default_context(): Context
4960
$data['user_id'] = 1000;
5061
}
5162

63+
$worktreeName = get_worktree_name($data['root_dir']);
64+
if (null !== $worktreeName) {
65+
$data['worktree_name'] = $worktreeName;
66+
$data['project_name'] .= '-wt-' . $worktreeName;
67+
} else {
68+
$data['worktree_name'] = null;
69+
}
70+
5271
return new Context(
5372
$data,
5473
pty: Process::isPtySupported(),

‎.castor/docker.php‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,11 @@ function about(): void
3636
io()->section('Available URLs for this project:');
3737
$urls = [variable('root_domain'), ...variable('extra_domains')];
3838

39+
$adminPort = get_worktree_ports(variable('worktree_name'))['admin'];
40+
3941
try {
4042
$routers = http_client()
41-
->request('GET', \sprintf('http://%s:8080/api/http/routers', variable('root_domain')))
43+
->request('GET', \sprintf('http://%s:%d/api/http/routers', variable('root_domain'), $adminPort))
4244
->toArray()
4345
;
4446
$projectName = variable('project_name');
@@ -58,7 +60,13 @@ function about(): void
5860
} catch (HttpExceptionInterface) {
5961
}
6062

61-
io()->listing(array_map(static fn ($url) => "https://{$url}", array_unique($urls)));
63+
$worktreeName = get_worktree_name();
64+
$httpsPort = null !== $worktreeName ? get_worktree_ports($worktreeName)['https'] : null;
65+
$formatUrl = static function (string $host) use ($httpsPort): string {
66+
return null !== $httpsPort ? "https://{$host}:{$httpsPort}" : "https://{$host}";
67+
};
68+
69+
io()->listing(array_map($formatUrl, array_unique($urls)));
6270
}
6371

6472
#[AsTask(description: 'Opens the project in your browser', namespace: '', aliases: ['open'])]
@@ -379,6 +387,15 @@ function docker_compose(array $subCommand, ?Context $c = null, array $profiles =
379387
'REGISTRY' => $c['registry'] ?? '',
380388
]);
381389

390+
if ($c['worktree_name']) {
391+
$ports = get_worktree_ports($c['worktree_name']);
392+
$portsEnv = [];
393+
foreach (get_port_specs() as $key => $spec) {
394+
$portsEnv[$spec['env']] = (string) $ports[$key];
395+
}
396+
$c = $c->withEnvironment($portsEnv);
397+
}
398+
382399
if ($c['APP_ENV'] ?? false) {
383400
$c = $c->withEnvironment([
384401
'APP_ENV' => $c['APP_ENV'] ?? '',
@@ -608,3 +625,26 @@ function get_service_names(?string $profile = null): array
608625
{
609626
return array_keys(get_services($profile));
610627
}
628+
629+
#[AsTask(description: 'Displays the ports allocated for the current project', namespace: 'docker')]
630+
function ports(): void
631+
{
632+
$project = variable('project_name');
633+
$domain = variable('root_domain');
634+
$name = get_worktree_name();
635+
$ports = get_worktree_ports($name);
636+
637+
io()->title('Ports');
638+
$list = [['Project' => $project]];
639+
foreach (get_port_specs() as $key => $spec) {
640+
$scheme = 'admin' === $key ? 'http' : $key;
641+
$host = 'admin' === $key ? 'localhost' : $domain;
642+
$list[] = [$spec['label'] => "{$scheme}://{$host}:{$ports[$key]}"];
643+
}
644+
645+
if ($name) {
646+
array_unshift($list, ['Worktree' => $name]);
647+
}
648+
649+
io()->definitionList(...$list);
650+
}

‎.castor/worktree.php‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
namespace docker;
4+
5+
use function Castor\variable;
6+
7+
function read_worktree_gitdir(?string $rootDir = null): ?string
8+
{
9+
$rootDir ??= variable('root_dir');
10+
$gitFile = $rootDir . '/.git';
11+
12+
if (!is_file($gitFile)) {
13+
return null;
14+
}
15+
16+
$contents = file_get_contents($gitFile);
17+
18+
if (false === $contents || !str_starts_with($contents, 'gitdir:')) {
19+
return null;
20+
}
21+
22+
return trim(substr($contents, 7));
23+
}
24+
25+
function get_worktree_name(?string $rootDir = null): ?string
26+
{
27+
$gitdir = read_worktree_gitdir($rootDir);
28+
29+
if (null === $gitdir) {
30+
return null;
31+
}
32+
33+
return strtolower(basename(rtrim($gitdir, '/'))) ?: null;
34+
}
35+
36+
/** @return array<string, int> */
37+
function get_worktree_ports(?string $worktreeName): array
38+
{
39+
$specs = get_port_specs();
40+
41+
if (null === $worktreeName) {
42+
$defaults = [];
43+
foreach ($specs as $key => $spec) {
44+
$defaults[$key] = $spec['default'];
45+
}
46+
47+
return $defaults;
48+
}
49+
50+
$hash = crc32($worktreeName) & 0x7FFFFFFF;
51+
$base = 10000 + ($hash % 50000);
52+
53+
$i = 0;
54+
$ports = [];
55+
foreach ($specs as $key => $spec) {
56+
$ports[$key] = $base + $i;
57+
++$i;
58+
}
59+
60+
return $ports;
61+
}

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
* Do not store certificates in the router image
2626
* Do not hardcode a user in the Dockerfile (and so the image), map it dynamically
2727
* Mount the project in `/var/www` instead of `/home/app`
28+
* Add git worktree support (auto-isolated project name, ports, volumes, networks)
2829
* Add support for caching image cache in a registry
2930
* Upgrade base to Debian Bookworm (12.5)
3031

‎README.dist.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,16 @@ You can run `castor docker:generate-certificates --force` to recreate new certif
8686
if some were already generated. Remember to restart the infrastructure to make
8787
use of the new certificates with `castor build && castor up` or `castor start`.
8888

89+
### Git worktree support
90+
91+
This stack supports [git worktrees](https://git-scm.com/docs/git-worktree)
92+
out of the box. If you run `castor start` inside a worktree, the infrastructure
93+
is fully isolated (project name, volumes, networks, ports) without any
94+
configuration.
95+
96+
Ports are displayed at the end of `castor start`, or run `castor docker:ports`
97+
to see them at any time.
98+
8999
### Builder
90100

91101
Having some composer, yarn or other modifications to make on the project?

‎README.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,16 @@ Will give you `https://app.test`, `https://www.app.test`,
6161
> Some castor tasks have been added for DX purposes. Checkout and adapt
6262
> the tasks `install`, `migrate` and `cache_clear` to your project.
6363
64+
## Git worktree support
65+
66+
When running `castor` commands inside a [git worktree](https://git-scm.com/docs/git-worktree),
67+
the stack is **automatically isolated** from other worktrees:
68+
69+
- Docker Compose **project name**, **volumes**, and **networks** are suffixed with `-wt-<worktree-name>`
70+
- **Ports** are derived from the worktree name (use `castor docker:ports` to display them)
71+
72+
This allows running multiple feature branches in parallel without conflicts.
73+
6474
## Usage documentation
6575

6676
We provide a [README.dist.md](./README.dist.md) to bootstrap your project

‎infrastructure/docker/docker-compose.dev.yml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ services:
55
- "/var/run/docker.sock:/var/run/docker.sock"
66
- "./services/router/certs:/etc/ssl/certs"
77
ports:
8-
- "80:80"
9-
- "443:443"
10-
- "8080:8080"
8+
- "${PROJECT_HTTP_PORT:-80}:80"
9+
- "${PROJECT_HTTPS_PORT:-443}:443"
10+
- "${PROJECT_ADMIN_PORT:-8080}:8080"
1111
networks:
1212
- default
1313
profiles:

0 commit comments

Comments
 (0)