Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions scripts/dev.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ set -eu

cd "$(dirname "$0")/.."
WORKTREE_ROOT="$(pwd -P)"
cd "$WORKTREE_ROOT"

usage() {
cat >&2 <<'EOF'
Expand Down
62 changes: 62 additions & 0 deletions scripts/docker-compose.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
set -eu

cd "$(dirname "$0")/.."
WORKTREE_ROOT="$(pwd -P)"
cd "$WORKTREE_ROOT"

# Prefer developer-provided .env values, but keep .env.example as the baseline
# so Compose validation works before a local .env exists.
Expand Down Expand Up @@ -36,10 +38,70 @@ load_port_reservations() {

load_port_reservations "$ENV_FILE"

eval "$(./scripts/worktree-ports.sh export)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve .env port overrides when exporting Compose name

When a developer sets POSTGRES_HOST_PORT or REDIS_HOST_PORT in .env, this eval exports the generated values into the shell before the later docker compose --env-file ... --env-file .env call. Docker Compose interpolation precedence puts “variables from your shell environment” ahead of variables from --env-file (docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/), so the local .env no longer has the final override authority promised by this wrapper and Compose will still bind the generated ports.

Useful? React with 👍 / 👎.


PORT_ENV_FILE="$(mktemp)"
trap 'rm -f "$PORT_ENV_FILE"' EXIT HUP INT TERM
./scripts/worktree-ports.sh env > "$PORT_ENV_FILE"

reclaim_same_worktree_compose_containers() {
command -v docker >/dev/null 2>&1 || return 0

infra_ports=" ${POSTGRES_HOST_PORT} ${REDIS_HOST_PORT} "
publishes_assigned_port() {
port_list=$1
for port in $infra_ports; do
case "$port_list" in
*":${port}->"*) return 0 ;;
esac
done
return 1
}

compose_containers=$(
docker ps -a \
--format '{{.ID}}\t{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.project.working_dir"}}\t{{.Ports}}\t{{.Names}}'
)
stale_containers=$(
printf '%s\n' "$compose_containers" | while IFS="$(printf '\t')" read -r container_id project_name working_dir port_list container_name; do
if [ "$project_name" = "$COMPOSE_PROJECT_NAME" ] || [ -z "$working_dir" ] || [ ! -d "$working_dir" ]; then
continue
fi

working_dir_realpath=$(CDPATH= cd "$working_dir" 2>/dev/null && pwd -P)
if [ "$working_dir_realpath" != "$WORKTREE_ROOT" ]; then
continue
fi

if ! publishes_assigned_port "$port_list"; then
continue
fi

printf '%s\t%s\t%s\t%s\n' "$container_id" "$project_name" "$container_name" "$working_dir"
done
)

if [ -z "$stale_containers" ]; then
return 0
fi

echo "Reclaiming stale same-worktree Docker Compose containers:"
printf '%s\n' "$stale_containers" | while IFS="$(printf '\t')" read -r _container_id project_name container_name working_dir; do
printf ' %s (%s, %s)\n' "$container_name" "$project_name" "$working_dir"
done

# Only stale same-realpath containers that publish this worktree's assigned
# infra ports are reclaimed. One-shot helper containers without host ports are
# harmless and are left alone.
docker rm -f $(printf '%s\n' "$stale_containers" | awk '{ print $1 }') >/dev/null
}

case "${1:-}" in
up)
Comment on lines +99 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Detect up after Compose global options

When an adapted repo invokes the wrapper with Docker Compose global options before the subcommand, such as ./scripts/docker-compose.sh --profile debug up -d (the Compose CLI documents --profile and other options at the docker compose level), $1 is not up, so the stale same-worktree cleanup is skipped and the old symlink-named containers can still hold the assigned Postgres/Redis ports. Parse past Compose global options before deciding whether this invocation is an up.

Useful? React with 👍 / 👎.

reclaim_same_worktree_compose_containers
;;
esac

# Env-file order is significant: examples provide defaults, generated ports
# make sibling worktrees safe, and .env has final local override authority.
if [ "$ENV_FILE" = ".env" ]; then
Expand Down
18 changes: 17 additions & 1 deletion scripts/worktree-ports.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ usage() {

worktree_root() {
if root="$(git rev-parse --show-toplevel 2>/dev/null)"; then
printf '%s\n' "$root"
CDPATH= cd "$root" && pwd -P
else
pwd -P
fi
Expand Down Expand Up @@ -68,6 +68,19 @@ port_block() {
echo $((BASE_PORT + ((value % blocks) * PORT_BLOCK_SIZE)))
}

compose_project_name() {
root="$(worktree_root)"
project="$(basename "$root" | tr -cs 'A-Za-z0-9' '-' | tr 'A-Z' 'a-z' | sed 's/^-*//; s/-*$//')"
if [ -z "$project" ]; then
project="worktree"
fi

digest="$(hash_hex "$root")"
prefix="$(printf '%s' "$digest" | cut -c 1-8)"
value="$(hex_to_decimal "$prefix")"
printf '%s-%04d\n' "$project" "$((value % 10000))"
}

is_positive_integer() {
case "${1:-}" in
''|*[!0-9]*) return 1 ;;
Expand Down Expand Up @@ -217,11 +230,13 @@ calculate_ports() {
WEB_URL="http://127.0.0.1:${WEB_PORT}"
WEB_API_BASE_URL="http://127.0.0.1:${API_PORT}"
OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:${OTEL_HTTP_PORT}"
COMPOSE_PROJECT_NAME="$(compose_project_name)"
}

print_env() {
prefix="$1"
calculate_ports
printf '%sCOMPOSE_PROJECT_NAME=%s\n' "$prefix" "$COMPOSE_PROJECT_NAME"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep WEB_URL first in worktree port output

For direct ./scripts/worktree-ports.sh env consumers, this inserts COMPOSE_PROJECT_NAME before WEB_URL, violating the documented output contract in docs/development.md:18-22 and README.md:127-130 that keeps WEB_URL first so workspace URL scanners open the web surface. In environments that depend on the first emitted URL/key, the web URL is no longer discoverable as intended.

Useful? React with 👍 / 👎.

printf '%sWEB_URL=%s\n' "$prefix" "$WEB_URL"
printf '%sWEB_PORT=%s\n' "$prefix" "$WEB_PORT"
printf '%sAPI_PORT=%s\n' "$prefix" "$API_PORT"
Expand All @@ -242,6 +257,7 @@ export_env() {
export POSTGRES_HOST_PORT REDIS_HOST_PORT
export OTEL_HTTP_PORT POSTGRES_URL DATABASE_URL REDIS_URL
export WEB_URL WEB_API_BASE_URL OTEL_EXPORTER_OTLP_ENDPOINT
export COMPOSE_PROJECT_NAME
}

has_override() {
Expand Down
145 changes: 145 additions & 0 deletions stacks/python/tests/test_root_worktree_scripts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
from __future__ import annotations

import os
import stat
import subprocess
from pathlib import Path

DEVKIT_ROOT = Path(__file__).resolve().parents[3]


def _run(
command: list[str],
*,
cwd: Path,
env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
command,
cwd=cwd,
env=env,
check=True,
capture_output=True,
text=True,
)


def _env_output(stdout: str) -> dict[str, str]:
values: dict[str, str] = {}
for line in stdout.splitlines():
key, _, value = line.partition("=")
values[key] = value
return values


def _fake_docker(tmp_path: Path, body: str) -> Path:
fake_bin = tmp_path / "bin"
fake_bin.mkdir()
docker = fake_bin / "docker"
docker.write_text(body, encoding="utf-8")
docker.chmod(docker.stat().st_mode | stat.S_IXUSR)
return fake_bin


def test_shell_worktree_ports_canonicalizes_symlinked_git_root(
tmp_path: Path,
) -> None:
alias_root = tmp_path / "devkit-alias"
alias_root.symlink_to(DEVKIT_ROOT, target_is_directory=True)

real = _run(
[str(DEVKIT_ROOT / "scripts" / "worktree-ports.sh"), "env"],
cwd=DEVKIT_ROOT,
)
alias = _run(
[str(alias_root / "scripts" / "worktree-ports.sh"), "env"],
cwd=alias_root,
)

real_env = _env_output(real.stdout)
alias_env = _env_output(alias.stdout)

assert alias_env["COMPOSE_PROJECT_NAME"] == real_env["COMPOSE_PROJECT_NAME"]
assert alias_env["POSTGRES_HOST_PORT"] == real_env["POSTGRES_HOST_PORT"]
assert alias_env["REDIS_HOST_PORT"] == real_env["REDIS_HOST_PORT"]


def test_docker_compose_wrapper_exports_canonical_project_for_symlink(
tmp_path: Path,
) -> None:
alias_root = tmp_path / "devkit-alias"
alias_root.symlink_to(DEVKIT_ROOT, target_is_directory=True)
fake_bin = _fake_docker(
tmp_path,
"""#!/bin/sh
if [ "$1" = "compose" ]; then
printf '%s\\n' "$COMPOSE_PROJECT_NAME"
exit 0
fi
exit 2
""",
)
env = os.environ.copy()
env["PATH"] = f"{fake_bin}:{env['PATH']}"

real = _run(
[str(DEVKIT_ROOT / "scripts" / "docker-compose.sh"), "config"],
cwd=tmp_path,
env=env,
)
alias = _run(
[str(alias_root / "scripts" / "docker-compose.sh"), "config"],
cwd=tmp_path,
env=env,
)

assert alias.stdout == real.stdout


def test_docker_compose_wrapper_reclaims_only_same_realpath_stale_containers(
tmp_path: Path,
) -> None:
alias_root = tmp_path / "devkit-alias"
alias_root.symlink_to(DEVKIT_ROOT, target_is_directory=True)
sibling_root = tmp_path / "sibling-worktree"
sibling_root.mkdir()
log_path = tmp_path / "docker.log"
fake_bin = _fake_docker(
tmp_path,
"""#!/bin/sh
if [ "$1" = "ps" ]; then
printf '%s\\n' "$FAKE_DOCKER_PS"
exit 0
fi
if [ "$1" = "rm" ]; then
printf 'rm %s\\n' "$*" >> "$FAKE_DOCKER_LOG"
exit 0
fi
if [ "$1" = "compose" ]; then
printf 'compose %s\\n' "$COMPOSE_PROJECT_NAME" >> "$FAKE_DOCKER_LOG"
exit 0
fi
exit 2
""",
)
env = os.environ.copy()
env["PATH"] = f"{fake_bin}:{env['PATH']}"
env["FAKE_DOCKER_LOG"] = str(log_path)
env["FAKE_DOCKER_PS"] = "\n".join(
[
f"same-port\told-project\t{alias_root}\t127.0.0.1:9540->5432/tcp\told-postgres",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive fake Docker port from the assigned worktree port

This hard-coded 9540 only matches checkouts whose path hash assigns Postgres to that exact port; in other workspaces the wrapper looks for the generated POSTGRES_HOST_PORT instead, so the fake stale container is not reclaimed and assert "same-port" in log fails (for example this checkout assigns POSTGRES_HOST_PORT=9140). Derive the fake Ports value from ./scripts/worktree-ports.sh env or reserve the block in the test so it is deterministic across CI paths.

Useful? React with 👍 / 👎.

f"same-helper\told-project\t{alias_root}\t\told-init",
f"sibling-port\told-project\t{sibling_root}\t127.0.0.1:9540->5432/tcp\tsibling-postgres",
]
)

_run(
[str(alias_root / "scripts" / "docker-compose.sh"), "up", "-d", "postgres"],
cwd=tmp_path,
env=env,
)

log = log_path.read_text(encoding="utf-8")
assert "same-port" in log
assert "same-helper" not in log
assert "sibling-port" not in log
Loading