-
Notifications
You must be signed in to change notification settings - Fork 1
[codex] Stabilize worktree compose project names #11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Comment on lines
+99
to
+100
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an adapted repo invokes the wrapper with Docker Compose global options before the subcommand, such as 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For direct 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" | ||
|
|
@@ -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() { | ||
|
|
||
| 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", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This hard-coded 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 | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a developer sets
POSTGRES_HOST_PORTorREDIS_HOST_PORTin.env, thisevalexports the generated values into the shell before the laterdocker compose --env-file ... --env-file .envcall. 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.envno longer has the final override authority promised by this wrapper and Compose will still bind the generated ports.Useful? React with 👍 / 👎.