From c21bbfe992542540578e17f01b27a3655ad60daa Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Wed, 8 Jul 2026 16:32:51 -0700 Subject: [PATCH] Stabilize worktree compose project names --- scripts/dev.sh | 1 + scripts/docker-compose.sh | 62 ++++++++ scripts/worktree-ports.sh | 18 ++- .../tests/test_root_worktree_scripts.py | 145 ++++++++++++++++++ 4 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 stacks/python/tests/test_root_worktree_scripts.py diff --git a/scripts/dev.sh b/scripts/dev.sh index cbf1de2..5e933ec 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -3,6 +3,7 @@ set -eu cd "$(dirname "$0")/.." WORKTREE_ROOT="$(pwd -P)" +cd "$WORKTREE_ROOT" usage() { cat >&2 <<'EOF' diff --git a/scripts/docker-compose.sh b/scripts/docker-compose.sh index b0b6f8b..30f6375 100755 --- a/scripts/docker-compose.sh +++ b/scripts/docker-compose.sh @@ -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. @@ -36,10 +38,70 @@ load_port_reservations() { load_port_reservations "$ENV_FILE" +eval "$(./scripts/worktree-ports.sh export)" + 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) + 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 diff --git a/scripts/worktree-ports.sh b/scripts/worktree-ports.sh index 81fcaa3..8d0a45c 100755 --- a/scripts/worktree-ports.sh +++ b/scripts/worktree-ports.sh @@ -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 @@ -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 ;; @@ -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" printf '%sWEB_URL=%s\n' "$prefix" "$WEB_URL" printf '%sWEB_PORT=%s\n' "$prefix" "$WEB_PORT" printf '%sAPI_PORT=%s\n' "$prefix" "$API_PORT" @@ -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() { diff --git a/stacks/python/tests/test_root_worktree_scripts.py b/stacks/python/tests/test_root_worktree_scripts.py new file mode 100644 index 0000000..715d48a --- /dev/null +++ b/stacks/python/tests/test_root_worktree_scripts.py @@ -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", + 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