From 06a2173cbc4cb6a9142308e78365928e8e06d529 Mon Sep 17 00:00:00 2001 From: David Gamez Diaz <1192523+davidgamez@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:57:48 -0400 Subject: [PATCH] support multiple worktrees at once --- .github/copilot-instructions.md | 2 +- CLAUDE.md | 9 +- README.md | 23 ++- .../integration/cascade_delete/conftest.py | 3 +- api/tests/integration/conftest.py | 3 +- .../integration/populate_tests/conftest.py | 3 +- .../populate_twice_gbfs/conftest.py | 3 +- api/tests/test_utils/db_url.py | 38 ++++ api/tests/unittest/conftest.py | 3 +- api/tests/unittest/user_service/conftest.py | 9 +- config/.env.local | 5 + docker-compose.yaml | 61 ++++--- functions-python/export_csv/src/main.py | 6 +- functions-python/test_utils/database_utils.py | 21 ++- .../test_utils/liquibase.properties | 4 + scripts/api-operations-surface.sh | 11 +- scripts/api-tests.sh | 6 + scripts/db-gen-user.sh | 6 +- scripts/db-gen.sh | 7 +- scripts/docker-localdb-cleanup.sh | 141 +++++++++++++++ scripts/docker-localdb-rebuild-data.sh | 54 +++--- scripts/init-local-folder.sh | 10 ++ scripts/populate-db-test-data-users.sh | 18 +- scripts/populate-licenses.sh | 12 +- scripts/worktree-env.sh | 166 ++++++++++++++++++ 25 files changed, 554 insertions(+), 70 deletions(-) create mode 100644 api/tests/test_utils/db_url.py create mode 100755 scripts/docker-localdb-cleanup.sh create mode 100755 scripts/worktree-env.sh diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c4ca8c90b..799195c4d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -27,7 +27,7 @@ scripts/setup-openapi-generator.sh cd api && pip3 install -r requirements.txt -r requirements_dev.txt # Start local database -docker-compose --env-file ./config/.env.local up -d --force-recreate +scripts/docker-localdb-rebuild-data.sh # per-worktree project + ports; prefer over bare `docker compose up` # Generate API stubs (run after schema changes) scripts/api-gen.sh diff --git a/CLAUDE.md b/CLAUDE.md index ce0c32426..16f231c24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,9 @@ scripts/setup-openapi-generator.sh cd api && pip3 install -r requirements.txt -r requirements_dev.txt # Local Postgres + Liquibase migrations -docker-compose --env-file ./config/.env.local up -d --force-recreate +scripts/docker-localdb-rebuild-data.sh # prefer this over a bare `docker compose up`: + # it loads config/.env.worktree, which pins this + # worktree's Compose project and Postgres ports # Re-init local env after checking out a branch: rebuilds main+test DBs, regenerates # SQLAlchemy models, FastAPI stubs (Feeds + User Service + Operations API), and @@ -84,7 +86,10 @@ scripts/function-python-run.sh --function_name # installs deps into a scripts/function-python-build.sh --function_name # zips into .dist/ for deploy ``` -`api-tests.sh` copies `config/.env.local` to `.env` before running, so local env vars match the docker-compose Postgres instance. +`api-tests.sh` copies `config/.env.local` to `.env` before running, then appends `config/.env.worktree` if present, so local env vars match this worktree's docker-compose Postgres instance. + +### Per-worktree local databases +Each git worktree runs its own Compose project, with its own published Postgres host ports and its own named data volumes, so several worktrees can run their stacks and test suites concurrently. `scripts/worktree-env.sh` generates `config/.env.worktree` (gitignored) on first use and every other script sources it; CI never creates that file, so CI keeps the default `5432` / `54320`. Release the resources with `scripts/docker-localdb-cleanup.sh` (add `--all` to reclaim idle leftovers from deleted worktrees). Note `config/.env.local` is *tracked* - never put per-worktree values there. ## Architecture Notes diff --git a/README.md b/README.md index cf74ea15e..81a676889 100644 --- a/README.md +++ b/README.md @@ -91,9 +91,30 @@ pip3 install -r requirements_dev.txt - Generates an instance of the database locally using docker-compose ```bash -docker-compose --env-file ./config/.env.local up -d --force-recreate +scripts/docker-localdb-rebuild-data.sh ``` + Prefer this over a bare `docker compose up`. Each git worktree gets its own + Compose project and its own published Postgres ports (generated once into + `config/.env.worktree` by `scripts/worktree-env.sh`), so several worktrees can + run their databases - and their test suites - at the same time. A bare + `docker compose up` does not load that file and will fall back to the shared + default ports, colliding with whichever worktree already holds them. + +- Releases the Docker resources again when you are done with a worktree + +```bash +scripts/docker-localdb-cleanup.sh # this worktree only +scripts/docker-localdb-cleanup.sh --all # also reclaim idle leftovers of deleted worktrees +``` + + Worth knowing: every Compose project creates its own Docker network, and + Docker's default address pool only fits about 31 of them. Worktrees that are + deleted without being torn down leak a network each; once the pool is + exhausted, every `docker compose up` fails with "all predefined address pools + have been fully subnetted", which shows up as a flood of connection errors + from liquibase and the db-gen scripts. + - Generates the api and database stubs on the first run and every time the schema changes ```bash diff --git a/api/tests/integration/cascade_delete/conftest.py b/api/tests/integration/cascade_delete/conftest.py index 193bc1d5e..7b4e3d6c6 100644 --- a/api/tests/integration/cascade_delete/conftest.py +++ b/api/tests/integration/cascade_delete/conftest.py @@ -8,6 +8,7 @@ from shared.database.database import Database from main import app as application from tests.test_utils.database import populate_database +from tests.test_utils.db_url import feeds_test_database_url @pytest.fixture(scope="package") @@ -19,7 +20,7 @@ def app() -> FastAPI: @pytest.fixture(scope="package") def test_database(): # Restrict the tests to the test database - os.environ["FEEDS_DATABASE_URL"] = "postgresql://postgres:postgres@localhost:54320/MobilityDatabaseTest" + os.environ["FEEDS_DATABASE_URL"] = feeds_test_database_url() data_dirs = [] second_phase_data_dirs = [] diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index 879bdbbf6..cc12e0f11 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -7,6 +7,7 @@ from shared.database.database import Database from main import app as application from tests.test_utils.database import populate_database +from tests.test_utils.db_url import feeds_test_database_url @pytest.fixture(scope="package") @@ -18,7 +19,7 @@ def app() -> FastAPI: @pytest.fixture(scope="package") def test_database(): # Restrict the tests to the test database - os.environ["FEEDS_DATABASE_URL"] = "postgresql://postgres:postgres@localhost:54320/MobilityDatabaseTest" + os.environ["FEEDS_DATABASE_URL"] = feeds_test_database_url() current_path = os.path.dirname(os.path.abspath(__file__)) diff --git a/api/tests/integration/populate_tests/conftest.py b/api/tests/integration/populate_tests/conftest.py index 9f6ad55d4..8a9375a28 100644 --- a/api/tests/integration/populate_tests/conftest.py +++ b/api/tests/integration/populate_tests/conftest.py @@ -7,6 +7,7 @@ from shared.database.database import Database from main import app as application from tests.test_utils.database import populate_database +from tests.test_utils.db_url import feeds_test_database_url @pytest.fixture(scope="package") @@ -18,7 +19,7 @@ def app() -> FastAPI: @pytest.fixture(scope="package") def test_database(): # Restrict the tests to the test database - os.environ["FEEDS_DATABASE_URL"] = "postgresql://postgres:postgres@localhost:54320/MobilityDatabaseTest" + os.environ["FEEDS_DATABASE_URL"] = feeds_test_database_url() current_path = os.path.dirname(os.path.abspath(__file__)) diff --git a/api/tests/integration/populate_twice_gbfs/conftest.py b/api/tests/integration/populate_twice_gbfs/conftest.py index bf8571e6d..142b350db 100644 --- a/api/tests/integration/populate_twice_gbfs/conftest.py +++ b/api/tests/integration/populate_twice_gbfs/conftest.py @@ -7,6 +7,7 @@ from shared.database.database import Database from main import app as application from tests.test_utils.database import populate_database +from tests.test_utils.db_url import feeds_test_database_url @pytest.fixture(scope="package") @@ -18,7 +19,7 @@ def app() -> FastAPI: @pytest.fixture(scope="package") def test_database(): # Restrict the tests to the test database - os.environ["FEEDS_DATABASE_URL"] = "postgresql://postgres:postgres@localhost:54320/MobilityDatabaseTest" + os.environ["FEEDS_DATABASE_URL"] = feeds_test_database_url() current_path = os.path.dirname(os.path.abspath(__file__)) diff --git a/api/tests/test_utils/db_url.py b/api/tests/test_utils/db_url.py new file mode 100644 index 000000000..b300c6992 --- /dev/null +++ b/api/tests/test_utils/db_url.py @@ -0,0 +1,38 @@ +"""Resolution of the test database URLs. + +These URLs are per worktree. ``scripts/worktree-env.sh`` publishes Postgres on a +different host port in each git worktree so that several stacks - and several +test runs - can be active at once, and writes the resulting URLs into +``config/.env.worktree``. ``scripts/api-tests.sh`` merges that file into the +``.env`` that sits next to the tests. + +The literals below are the values for a checkout with no such override, and for +CI, which never creates one. Behaviour is therefore unchanged when it is absent. +""" + +import os +from typing import Final + +from dotenv import load_dotenv + +# Populate os.environ from the .env that scripts/api-tests.sh writes next to the +# tests. load_dotenv never overrides a variable already set in the environment. +load_dotenv() + +DEFAULT_FEEDS_TEST_DATABASE_URL: Final[str] = "postgresql://postgres:postgres@localhost:54320/MobilityDatabaseTest" + +DEFAULT_USERS_TEST_DATABASE_URL: Final[str] = "postgresql://postgres:postgres@localhost:54320/MobilityDatabaseUsersTest" + + +def feeds_test_database_url() -> str: + """URL of the feeds test database for this worktree.""" + return os.getenv("FEEDS_DATABASE_URL_TEST", DEFAULT_FEEDS_TEST_DATABASE_URL) + + +def users_test_database_url() -> str: + """URL of the users test database for this worktree. + + ``TEST_USERS_DATABASE_URL`` is honoured first because it predates the + per-worktree override and is already used to redirect individual tests. + """ + return os.getenv("TEST_USERS_DATABASE_URL") or os.getenv("USERS_DATABASE_URL_TEST", DEFAULT_USERS_TEST_DATABASE_URL) diff --git a/api/tests/unittest/conftest.py b/api/tests/unittest/conftest.py index d443fe2ea..6f4b3e3a6 100644 --- a/api/tests/unittest/conftest.py +++ b/api/tests/unittest/conftest.py @@ -7,6 +7,7 @@ from shared.database.database import Database from main import app as application from tests.test_utils.database import populate_database +from tests.test_utils.db_url import feeds_test_database_url @pytest.fixture(scope="package") @@ -19,7 +20,7 @@ def app() -> FastAPI: def test_database(): # Restrict the tests to the test database - os.environ["FEEDS_DATABASE_URL"] = "postgresql://postgres:postgres@localhost:54320/MobilityDatabaseTest" + os.environ["FEEDS_DATABASE_URL"] = feeds_test_database_url() current_path = os.path.dirname(os.path.abspath(__file__)) data_dirs = [current_path + "/../test_data"] diff --git a/api/tests/unittest/user_service/conftest.py b/api/tests/unittest/user_service/conftest.py index b52355729..9cc6df926 100644 --- a/api/tests/unittest/user_service/conftest.py +++ b/api/tests/unittest/user_service/conftest.py @@ -6,9 +6,12 @@ # Ensure src is on the path for these standalone unit tests. sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../../../src")) -# Users test database, matching the Liquibase target in the CI "Run tests" job +from tests.test_utils.db_url import users_test_database_url as resolve_users_test_database_url # noqa: E402 + +# Users test database. Per worktree - see tests/test_utils/db_url.py. Defaults to +# the CI "Run tests" Liquibase target # (liquibase update on jdbc:postgresql://localhost:54320/MobilityDatabaseUsersTest). -USERS_TEST_DATABASE_URL = "postgresql://postgres:postgres@localhost:54320/MobilityDatabaseUsersTest" +USERS_TEST_DATABASE_URL = resolve_users_test_database_url() @pytest.fixture @@ -20,7 +23,7 @@ def users_test_database_url(): ``USERS_DATABASE_URL`` and on the CI test database. """ previous = os.environ.get("USERS_DATABASE_URL") - os.environ["USERS_DATABASE_URL"] = os.getenv("TEST_USERS_DATABASE_URL", USERS_TEST_DATABASE_URL) + os.environ["USERS_DATABASE_URL"] = USERS_TEST_DATABASE_URL try: yield os.environ["USERS_DATABASE_URL"] finally: diff --git a/config/.env.local b/config/.env.local index 41e73be3d..f5ed071c2 100644 --- a/config/.env.local +++ b/config/.env.local @@ -4,7 +4,11 @@ POSTGRES_DB=MobilityDatabase POSTGRES_TEST_DB=MobilityDatabaseTest POSTGRES_USER_DB=MobilityDatabaseUsers POSTGRES_USER_TEST_DB=MobilityDatabaseUsersTest +# In-container Postgres port. Do not make this per-worktree: the liquibase and +# schemaspy services use it for container-to-container URLs. POSTGRES_PORT=5432 +# Published host port for the main DB. Overridden per worktree in config/.env.worktree. +POSTGRES_HOST_PORT=5432 POSTGRES_TEST_PORT=54320 PGUSER=postgres POSTGRES_HOST=localhost @@ -13,6 +17,7 @@ SCHEMA_SPY_DOC=schemaspy-dev FEEDS_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/MobilityDatabase FEEDS_DATABASE_URL_TEST=postgresql://postgres:postgres@localhost:54320/MobilityDatabaseTest USERS_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/MobilityDatabaseUsers +USERS_DATABASE_URL_TEST=postgresql://postgres:postgres@localhost:54320/MobilityDatabaseUsersTest FEEDS_AUTHORIZATION=Bearer LOCAL_ENV=True PROJECT_ID=mobility-feeds-dev \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index 65f501a76..97ef41a21 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,14 +1,24 @@ +# The Compose project name is what isolates one git worktree's local stack from +# another's. scripts/worktree-env.sh generates a stable per-worktree value in +# config/.env.worktree; the fallback keeps a plain `docker compose` invocation +# (and CI, which never creates that file) on the historical behaviour. +name: ${COMPOSE_PROJECT_NAME:-mobility-feed-api} + services: postgres: - container_name: database image: postgis/postgis:15-3.5 # Fix warning message on arm64 machines platform: linux/amd64 healthcheck: - test: [ "CMD-SHELL", "pg_isready" ] - interval: 20s + # Must probe over TCP, not the Unix socket. During first-time init the + # entrypoint runs a temporary server with listen_addresses='' that answers + # on the socket, so a bare `pg_isready` reports healthy while TCP is still + # refused - releasing the liquibase services too early. + test: [ "CMD-SHELL", "pg_isready -h 127.0.0.1 -p ${POSTGRES_PORT} -U ${POSTGRES_USER}" ] + interval: 5s timeout: 5s - retries: 5 + retries: 10 + start_period: 30s environment: - POSTGRES_USER=${POSTGRES_USER} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} @@ -17,24 +27,29 @@ services: - POSTGRES_USER_DB=${POSTGRES_USER_DB} - PGUSER=${PGUSER} volumes: - - ./data:/var/lib/postgresql/data + # A named volume, not a bind mount: Docker Desktop's file sharing ignores + # chown on bind-mounted directories, so PGDATA stays root-owned and + # postgres (uid 999) refuses to start with "data directory has wrong + # ownership". The volume is project-scoped, so each worktree gets its own. + - pgdata:/var/lib/postgresql/data - ./liquibase/init:/docker-entrypoint-initdb.d expose: - ${POSTGRES_PORT} ports: - - ${POSTGRES_PORT}:${POSTGRES_PORT} + # Host port is per worktree; container port is always ${POSTGRES_PORT}. + - ${POSTGRES_HOST_PORT:-5432}:${POSTGRES_PORT} networks: - local postgres-test: - container_name: database_test image: postgis/postgis:15-3.5 # Fix warning message on arm64 machines platform: linux/amd64 healthcheck: - test: [ "CMD-SHELL", "pg_isready" ] - interval: 20s + test: [ "CMD-SHELL", "pg_isready -h 127.0.0.1 -p ${POSTGRES_PORT} -U ${POSTGRES_USER}" ] + interval: 5s timeout: 5s - retries: 5 + retries: 10 + start_period: 30s environment: - POSTGRES_USER=${POSTGRES_USER} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} @@ -43,17 +58,19 @@ services: - POSTGRES_USER_DB=${POSTGRES_USER_TEST_DB} - PGUSER=${PGUSER} volumes: - - ./data-test:/var/lib/postgresql/data + - pgdata_test:/var/lib/postgresql/data - ./liquibase/init:/docker-entrypoint-initdb.d expose: - - ${POSTGRES_TEST_PORT} + - ${POSTGRES_PORT} ports: - - ${POSTGRES_TEST_PORT}:${POSTGRES_PORT} + - ${POSTGRES_TEST_PORT:-54320}:${POSTGRES_PORT} networks: - - local + - local liquibase: image: liquibase/liquibase - restart: on-failure + # Capped: an uncapped on-failure policy turns a permanently broken migration + # (e.g. a missing JDBC driver jar) into a container that restarts forever. + restart: on-failure:5 volumes: - ./liquibase:/liquibase/changelog command: @@ -72,7 +89,7 @@ services: - local liquibase-test: image: liquibase/liquibase - restart: on-failure + restart: on-failure:5 volumes: - ./liquibase:/liquibase/changelog command: @@ -88,10 +105,10 @@ services: postgres-test: condition: service_healthy networks: - - local + - local liquibase-user: image: liquibase/liquibase - restart: on-failure + restart: on-failure:5 volumes: - ./liquibase:/liquibase/changelog command: @@ -110,7 +127,7 @@ services: - local liquibase-user-test: image: liquibase/liquibase - restart: on-failure + restart: on-failure:5 volumes: - ./liquibase:/liquibase/changelog command: @@ -129,7 +146,6 @@ services: - local schemaspy: image: andrewjones/schemaspy-postgres:latest - container_name: schema_generation command: [ "-db", "${POSTGRES_DB}", "-host", "postgres", "-port", "${POSTGRES_PORT}", "-s", "public", "-u", "${POSTGRES_USER}", "-p", "${POSTGRES_PASSWORD}" ] volumes: - ./docs/${SCHEMA_SPY_DOC}:/output @@ -138,6 +154,9 @@ services: condition: service_completed_successfully networks: - local +volumes: + pgdata: + pgdata_test: networks: local: - driver: bridge \ No newline at end of file + driver: bridge diff --git a/functions-python/export_csv/src/main.py b/functions-python/export_csv/src/main.py index 493818661..20ab40b67 100644 --- a/functions-python/export_csv/src/main.py +++ b/functions-python/export_csv/src/main.py @@ -469,8 +469,10 @@ def upload_file_to_storage(source_file_path, target_path): default=csv_default_file_path, help="Path to the output csv file.", ) - os.environ["FEEDS_DATABASE_URL"] = ( - "postgresql://postgres:postgres@localhost:54320/MobilityDatabaseTest" + # Per-worktree test DB (config/.env.worktree); falls back to the default port. + os.environ["FEEDS_DATABASE_URL"] = os.getenv( + "FEEDS_DATABASE_URL_TEST", + "postgresql://postgres:postgres@localhost:54320/MobilityDatabaseTest", ) args = parser.parse_args() export_csv(args.outpath) diff --git a/functions-python/test_utils/database_utils.py b/functions-python/test_utils/database_utils.py index 124bc135e..1a34daf79 100644 --- a/functions-python/test_utils/database_utils.py +++ b/functions-python/test_utils/database_utils.py @@ -14,8 +14,11 @@ # limitations under the License. # +import os from typing import Final +from dotenv import load_dotenv + from sqlalchemy.orm import Session from sqlalchemy import text @@ -27,12 +30,22 @@ logging.basicConfig() logging.getLogger("sqlalchemy").setLevel(logging.ERROR) -default_db_url: Final[str] = ( - "postgresql://postgres:postgres@localhost:54320/MobilityDatabaseTest" +# Test database URLs are per worktree: scripts/worktree-env.sh publishes Postgres +# on a different host port in each git worktree so several stacks - and several +# test runs - can be active at once, and scripts/api-tests.sh copies the +# resulting values into the .env next to these tests. The literals are the +# defaults for a checkout without that override, and for CI, which never +# creates one. +load_dotenv() + +default_db_url: Final[str] = os.getenv( + "FEEDS_DATABASE_URL_TEST", + "postgresql://postgres:postgres@localhost:54320/MobilityDatabaseTest", ) -default_users_db_url: Final[str] = ( - "postgresql://postgres:postgres@localhost:54320/MobilityDatabaseUsersTest" +default_users_db_url: Final[str] = os.getenv( + "USERS_DATABASE_URL_TEST", + "postgresql://postgres:postgres@localhost:54320/MobilityDatabaseUsersTest", ) excluded_tables: Final[list[str]] = [ diff --git a/functions-python/test_utils/liquibase.properties b/functions-python/test_utils/liquibase.properties index c6f90d97e..88e4e4d99 100644 --- a/functions-python/test_utils/liquibase.properties +++ b/functions-python/test_utils/liquibase.properties @@ -1,3 +1,7 @@ +# Convenience config for running the Liquibase CLI by hand against the test DB. +# The port below is the default. If this worktree has its own published port +# (see config/.env.worktree, generated by scripts/worktree-env.sh), substitute +# POSTGRES_TEST_PORT from that file into the url. changeLogFile=../liquibase/changelog.xml username=POSTGRES_USER password=POSTGRES_PASSWORD diff --git a/scripts/api-operations-surface.sh b/scripts/api-operations-surface.sh index a03863717..bbed24078 100755 --- a/scripts/api-operations-surface.sh +++ b/scripts/api-operations-surface.sh @@ -106,6 +106,9 @@ if [[ -z "$BASE_URL" ]]; then # 4. Test DB URLs (code reads FEEDS_DATABASE_URL / USERS_DATABASE_URL, not *_TEST). set -a; # shellcheck disable=SC1091 source "$ROOT/config/.env.local"; set +a + # Per-worktree Compose project and host ports, if this worktree has them. + # shellcheck source=./worktree-env.sh + source "$ROOT/scripts/worktree-env.sh" : "${FEEDS_DATABASE_URL_TEST:?FEEDS_DATABASE_URL_TEST not set in config/.env.local}" FEEDS_URL="$FEEDS_DATABASE_URL_TEST" USERS_URL="${USERS_DATABASE_URL_TEST:-${FEEDS_URL/MobilityDatabaseTest/MobilityDatabaseUsersTest}}" @@ -115,7 +118,13 @@ if [[ -z "$BASE_URL" ]]; then need docker DC="docker compose"; $DC version >/dev/null 2>&1 || DC="docker-compose" echo "Bringing up test database + migrations..." - $DC --env-file "$ROOT/config/.env.local" up -d postgres-test liquibase-test liquibase-user-test + # -f and -p are explicit: without them the Compose project name is derived + # from the caller's working directory, which contradicts this script's + # "runs from anywhere" contract and would target a different worktree. + worktree_env_compose_args + $DC "${WORKTREE_COMPOSE_ARGS[@]}" up -d --wait postgres-test + $DC "${WORKTREE_COMPOSE_ARGS[@]}" run --rm liquibase-test + $DC "${WORKTREE_COMPOSE_ARGS[@]}" run --rm liquibase-user-test fi # 6. Start the real Cloud Function entrypoint (functions-framework -> main). diff --git a/scripts/api-tests.sh b/scripts/api-tests.sh index 06ee85ca1..1748f86d1 100755 --- a/scripts/api-tests.sh +++ b/scripts/api-tests.sh @@ -86,7 +86,13 @@ while [[ $# -gt 0 ]]; do esac done +# Defaults first, then this worktree's overrides (if any) so its host ports win. +# Later duplicate keys take precedence in a dotenv file. CI never creates +# config/.env.worktree, so CI keeps the historical 5432 / 54320. cat $ABS_SCRIPTPATH/../config/.env.local > $ABS_SCRIPTPATH/../.env +if [ -f $ABS_SCRIPTPATH/../config/.env.worktree ]; then + cat $ABS_SCRIPTPATH/../config/.env.worktree >> $ABS_SCRIPTPATH/../.env +fi execute_tests() { printf "\nExecuting tests in $1\n" diff --git a/scripts/db-gen-user.sh b/scripts/db-gen-user.sh index 56e958b6c..8bb3350b0 100755 --- a/scripts/db-gen-user.sh +++ b/scripts/db-gen-user.sh @@ -22,6 +22,9 @@ OUT_FILE=$SCRIPT_PATH/../$FILENAME ENV_PATH=$SCRIPT_PATH/../config/.env.local source "$ENV_PATH" +# Per-worktree host ports, if this worktree has them (config/.env.worktree). +# shellcheck source=./worktree-env.sh +source "$SCRIPT_PATH/worktree-env.sh" rm -rf "$SCRIPT_PATH/../api/src/shared/users_database_gen/" mkdir "$SCRIPT_PATH/../api/src/shared/users_database_gen/" @@ -33,7 +36,8 @@ then rm ${SCRIPT_PATH}/sqlacodegen-user.log fi -PORT=$POSTGRES_PORT +# Connects from the host, so it needs the published host port. +PORT=${POSTGRES_HOST_PORT:-$POSTGRES_PORT} # The users DB lives on the same instance as the catalog DB in both local # (host postgres / postgres-test container) and prod (same Cloud SQL instance). DB=${POSTGRES_USER_DB:-MobilityDatabaseUsers} diff --git a/scripts/db-gen.sh b/scripts/db-gen.sh index 1bd4f4a88..e17ef9c3b 100755 --- a/scripts/db-gen.sh +++ b/scripts/db-gen.sh @@ -18,6 +18,9 @@ OUT_FILE=$SCRIPT_PATH/../$FILENAME ENV_PATH=$SCRIPT_PATH/../config/.env.local source "$ENV_PATH" +# Per-worktree host ports, if this worktree has them (config/.env.worktree). +# shellcheck source=./worktree-env.sh +source "$SCRIPT_PATH/worktree-env.sh" rm -rf "$SCRIPT_PATH/../api/src/shared/database_gen/" mkdir "$SCRIPT_PATH/../api/src/shared/database_gen/" @@ -29,7 +32,9 @@ then rm ${SCRIPT_PATH}/sqlacodegen.log fi -PORT=$POSTGRES_PORT +# sqlacodegen connects from the host, so it needs the published host port, +# not POSTGRES_PORT (which is the in-container port). +PORT=${POSTGRES_HOST_PORT:-$POSTGRES_PORT} DB=$POSTGRES_DB if [ "$USE_TEST_DB" = true ]; then PORT=$POSTGRES_TEST_PORT diff --git a/scripts/docker-localdb-cleanup.sh b/scripts/docker-localdb-cleanup.sh new file mode 100755 index 000000000..217a0fff0 --- /dev/null +++ b/scripts/docker-localdb-cleanup.sh @@ -0,0 +1,141 @@ +#!/bin/bash +# +# MobilityData 2026 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Reclaims the Docker resources left behind by the local database stack. +# +# Why this exists: each git worktree runs its own Compose project, and each +# project creates its own bridge network. Docker's default address pool only has +# room for about 31 networks, so worktrees that are deleted - or simply never +# torn down - eventually exhaust it and every `docker compose up` fails with +# "all predefined address pools have been fully subnetted", which surfaces as a +# flood of connection errors from liquibase, db-gen and the populate scripts. +# +# This script is deliberately scoped. By default it touches only the Compose +# project of the worktree you run it from. `--all` additionally reclaims +# resources of OTHER worktrees, but only ones that are demonstrably idle: it +# never removes a network that still has a container attached, and never removes +# anything from a project that has a running container. It does not run +# `docker network prune`, which would also remove unrelated non-Compose networks. +# +# Usage: +# ./docker-localdb-cleanup.sh # this worktree's stack only +# ./docker-localdb-cleanup.sh --volumes # ... and delete its database data +# ./docker-localdb-cleanup.sh --all # ... plus idle leftovers of other worktrees +# ./docker-localdb-cleanup.sh --dry-run # show what would be removed + +set -u + +SCRIPT_PATH="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd -P)" + +REMOVE_VOLUMES=false +SWEEP_ALL=false +DRY_RUN=false + +display_usage() { + printf "\nReclaims Docker resources left behind by the local database stack.\n\n" + echo "Usage: $0 [options]" + echo "Options:" + echo " --volumes Also delete this worktree's database volumes (destroys local data)." + echo " --all Also reclaim idle leftovers from other worktrees' projects." + echo " Removes only: (a) networks declared by this repo's compose" + echo " file with no container attached, and (b) finished liquibase" + echo " / schemaspy job containers. Never touches a project that has" + echo " something running, and never removes a database container." + echo " --dry-run Report what would be removed without removing it." + echo " --help Display help content." + exit 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --volumes) REMOVE_VOLUMES=true; shift ;; + --all) SWEEP_ALL=true; shift ;; + --dry-run) DRY_RUN=true; shift ;; + --help) display_usage ;; + *) echo "Unknown option: $1" >&2; display_usage ;; + esac +done + +run() { + if [ "$DRY_RUN" = true ]; then + echo " [dry-run] $*" + else + "$@" >/dev/null 2>&1 + fi +} + +# shellcheck source=./worktree-env.sh +source "$SCRIPT_PATH/worktree-env.sh" +worktree_env_compose_args + +PROJECT="${COMPOSE_PROJECT_NAME:-mobility-feed-api}" + +echo "==> Tearing down this worktree's stack (project: $PROJECT)" +if [ "$REMOVE_VOLUMES" = true ]; then + echo " including its database volumes" + run docker compose "${WORKTREE_COMPOSE_ARGS[@]}" down --remove-orphans --volumes +else + run docker compose "${WORKTREE_COMPOSE_ARGS[@]}" down --remove-orphans +fi + +if [ "$SWEEP_ALL" != true ]; then + echo "==> Done. Other worktrees were not touched (use --all to reclaim idle leftovers)." + exit 0 +fi + +# A project is "busy" if it currently has a running container. Those are skipped +# entirely, so a worktree you are actively working in is never disturbed. +busy_projects="$(docker ps --format '{{.Label "com.docker.compose.project"}}' | sort -u)" +is_busy() { + [ -n "$1" ] && printf '%s\n' "$busy_projects" | grep -Fxq "$1" +} + +# Only this repo's one-shot job containers. Restricting by image keeps the sweep +# away from other projects that happen to use Compose: a stray postgres/postgis +# container belongs to whoever created it and holds state, so it is never +# touched here - only the disposable migration/report jobs are. +echo "==> Reclaiming finished one-shot job containers" +for image in liquibase/liquibase andrewjones/schemaspy-postgres:latest; do + for cid in $(docker ps -a --filter status=exited --filter status=created --filter "ancestor=$image" --format '{{.ID}}'); do + proj="$(docker inspect -f '{{index .Config.Labels "com.docker.compose.project"}}' "$cid" 2>/dev/null)" + [ -z "$proj" ] && continue # not a Compose container - leave it alone + if is_busy "$proj"; then + continue + fi + name="$(docker inspect -f '{{.Name}}' "$cid" 2>/dev/null | sed 's#^/##')" + echo " removing container $name (project: $proj)" + run docker rm "$cid" + done +done + +echo "==> Reclaiming unused networks created by this repo's compose file" +# Filtered to the network declared in docker-compose.yaml ("local"), so unrelated +# Compose projects and hand-made networks are out of scope. +for net in $(docker network ls --filter label=com.docker.compose.network=local --format '{{.Name}}'); do + proj="$(docker network inspect -f '{{index .Labels "com.docker.compose.project"}}' "$net" 2>/dev/null)" + if is_busy "$proj"; then + continue + fi + attached="$(docker network inspect -f '{{len .Containers}}' "$net" 2>/dev/null)" + if [ "${attached:-0}" != "0" ]; then + echo " skipping $net ($attached container(s) still attached)" + continue + fi + echo " removing network $net" + run docker network rm "$net" +done + +echo "==> Done." diff --git a/scripts/docker-localdb-rebuild-data.sh b/scripts/docker-localdb-rebuild-data.sh index ce35c7271..b3a923a1e 100755 --- a/scripts/docker-localdb-rebuild-data.sh +++ b/scripts/docker-localdb-rebuild-data.sh @@ -15,7 +15,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # -# This script delete the data and the local database container. +# This script deletes the data and the local database container. # Then it downloads the latest csv file and populates the database applying the liquibase changes. # Usage: # ./docker-localdb-rebuild-data.sh --populate-db @@ -68,37 +68,47 @@ while [[ $# -gt 0 ]]; do esac done -container_name="database" +# Per-worktree Compose project and host ports, so this stack can run alongside +# other worktrees'. Generates config/.env.worktree on first use. +# shellcheck source=./worktree-env.sh +source "$SCRIPT_PATH/worktree-env.sh" --ensure + +db_service="postgres" docker_service="liquibase" docker_service_user="liquibase-user" -data_dir="$SCRIPT_PATH/../data" +data_volume="pgdata" if [ "$USE_TEST_DB" = true ]; then - container_name="database_test" + db_service="postgres-test" docker_service="liquibase-test" docker_service_user="liquibase-user-test" - data_dir="$SCRIPT_PATH/../data-test" + data_volume="pgdata_test" fi -# Stop and remove the container -docker stop $container_name -docker rm $container_name - -# delete the data -rm -rf $data_dir +worktree_env_compose_args +compose() { docker compose "${WORKTREE_COMPOSE_ARGS[@]}" "$@"; } -# Add a slight delay because sometimes Docker does not seem ready after the rm. -sleep 5 +# Stop and remove this worktree's DB container, then drop its data volume. +# Scoped to this Compose project only - other worktrees are untouched. +compose rm --stop --force "$db_service" +docker volume rm --force "${COMPOSE_PROJECT_NAME:-mobility-feed-api}_${data_volume}" >/dev/null 2>&1 -# Start the container and run the liquibase -docker compose --env-file $SCRIPT_PATH/../config/.env.local -f $SCRIPT_PATH/../docker-compose.yaml up -d $docker_service -# wait for the liquibase to finish -sleep 20 +# Start the database and block until it actually accepts TCP connections. +if ! compose up -d --wait "$db_service"; then + printf "\n---------\nFailure: %s did not become healthy.\n---------\n" "$db_service" + compose logs --tail 40 "$db_service" + exit 1 +fi -# Bring up the users-DB liquibase service. The users DB itself is created by -# liquibase/init/01-create-users-db.sh on first start of the postgres container. -docker compose --env-file $SCRIPT_PATH/../config/.env.local -f $SCRIPT_PATH/../docker-compose.yaml up -d $docker_service_user -sleep 10 +# Apply the migrations as one-shot jobs. `run --rm` surfaces liquibase's real +# exit code (and ignores the restart policy), so a failed migration stops the +# script here instead of silently leaving an unmigrated DB for db-gen.sh. +for service in "$docker_service" "$docker_service_user"; do + if ! compose run --rm "$service"; then + printf "\n---------\nFailure: liquibase service '%s' failed.\n---------\n" "$service" + exit 1 + fi +done # generate the models $SCRIPT_PATH/db-gen.sh @@ -107,7 +117,7 @@ $SCRIPT_PATH/db-gen-user.sh if [ "$POPULATE_DB" = true ]; then # download the latest csv file and populate the db - mkdir $SCRIPT_PATH/../data/ + mkdir -p $SCRIPT_PATH/../data/ wget -O $SCRIPT_PATH/../data/$target_csv_file https://storage.googleapis.com/storage/v1/b/mdb-csv/o/sources.csv?alt=media # populate licenses before feeds so that feed.license_id FK references are satisfied $SCRIPT_PATH/populate-licenses.sh diff --git a/scripts/init-local-folder.sh b/scripts/init-local-folder.sh index 6f6781e7b..1843a49b3 100755 --- a/scripts/init-local-folder.sh +++ b/scripts/init-local-folder.sh @@ -28,6 +28,11 @@ # # Dependencies: # docker, docker-compose, wget, openapi-generator (setup-openapi-generator.sh), yq (v4+) +# +# Each git worktree gets its own Compose project and its own published Postgres +# ports (see scripts/worktree-env.sh), so several worktrees can run their stacks +# and test suites concurrently. Use scripts/docker-localdb-cleanup.sh to release +# them again. # relative path SCRIPT_PATH="$(dirname -- "${BASH_SOURCE[0]}")" @@ -44,6 +49,11 @@ while [[ $# -gt 0 ]]; do esac done +# Establish this worktree's Compose project name and host ports first, so every +# step below targets this worktree's stack and not another's. +echo "==> Resolving per-worktree Docker/Postgres settings..." +"$SCRIPT_PATH/worktree-env.sh" --ensure + echo "==> Rebuilding main local database..." if [ "$POPULATE_DB" = true ]; then "$SCRIPT_PATH/docker-localdb-rebuild-data.sh" --populate-db diff --git a/scripts/populate-db-test-data-users.sh b/scripts/populate-db-test-data-users.sh index 655a4d790..372b262e0 100755 --- a/scripts/populate-db-test-data-users.sh +++ b/scripts/populate-db-test-data-users.sh @@ -30,16 +30,24 @@ set -a source "$ENV_FILE" set +a +# Per-worktree Compose project, so this targets this worktree's stack. +# shellcheck source=./worktree-env.sh +source "$SCRIPT_PATH/worktree-env.sh" +worktree_env_compose_args + USER_DB="${POSTGRES_USER_DB:-MobilityDatabaseUsers}" -CONTAINER="database" +DB_SERVICE="postgres" if [ "${USE_TEST_DB:-false}" = "true" ]; then - CONTAINER="database_test" + USER_DB="${POSTGRES_USER_TEST_DB:-MobilityDatabaseUsersTest}" + DB_SERVICE="postgres-test" fi -echo "Loading dummy users data into $CONTAINER / $USER_DB ..." -docker exec -i \ +# Addressed by Compose *service* name, not container name: container names are +# project-prefixed so that several worktrees can run their stacks at once. +echo "Loading dummy users data into $DB_SERVICE / $USER_DB ..." +docker compose "${WORKTREE_COMPOSE_ARGS[@]}" exec -T \ -e PGPASSWORD="$POSTGRES_PASSWORD" \ - "$CONTAINER" \ + "$DB_SERVICE" \ psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$USER_DB" < "$SQL_FILE" echo "Done." diff --git a/scripts/populate-licenses.sh b/scripts/populate-licenses.sh index ad506df80..90c645206 100755 --- a/scripts/populate-licenses.sh +++ b/scripts/populate-licenses.sh @@ -65,11 +65,21 @@ if [[ -f "$REPO_ROOT/config/.env.local" ]]; then set +o allexport fi +# Per-worktree overrides last, so this worktree's host port wins over the +# defaults just loaded from config/.env.local. +if [[ -f "$REPO_ROOT/config/.env.worktree" ]]; then + echo "INFO: Loading env vars from $REPO_ROOT/config/.env.worktree" + set -o allexport + # shellcheck disable=SC1090 + source "$REPO_ROOT/config/.env.worktree" + set +o allexport +fi + # If FEEDS_DATABASE_URL is still not set, attempt to construct it from POSTGRES_* vars if [[ -z "${FEEDS_DATABASE_URL:-}" ]]; then if [[ -n "${POSTGRES_USER:-}" && -n "${POSTGRES_PASSWORD:-}" && -n "${POSTGRES_DB:-}" ]]; then DB_HOST="${POSTGRES_HOST:-localhost}" - DB_PORT="${POSTGRES_PORT:-5432}" + DB_PORT="${POSTGRES_HOST_PORT:-${POSTGRES_PORT:-5432}}" FEEDS_DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${DB_HOST}:${DB_PORT}/${POSTGRES_DB}" export FEEDS_DATABASE_URL echo "INFO: Constructed FEEDS_DATABASE_URL from POSTGRES_* variables." diff --git a/scripts/worktree-env.sh b/scripts/worktree-env.sh new file mode 100755 index 000000000..7e2300125 --- /dev/null +++ b/scripts/worktree-env.sh @@ -0,0 +1,166 @@ +#!/bin/bash +# +# MobilityData 2026 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Per-worktree Docker/Postgres identity. +# +# Every git worktree gets its own Compose project name and its own published +# Postgres host ports, so several worktrees can run their local stacks - and +# their test suites - at the same time without colliding. +# +# The values live in config/.env.worktree, which is generated on first use and +# reused afterwards so a worktree keeps the same ports across runs. That file is +# gitignored (.gitignore: config/.env.*) and must never be committed; +# config/.env.local is tracked and holds the defaults only. +# +# CI never generates this file, so CI keeps the historical 5432 / 54320. +# +# Usage: +# source scripts/worktree-env.sh # load config/.env.worktree if it exists +# source scripts/worktree-env.sh --ensure # generate it first if missing +# scripts/worktree-env.sh --print # show the resolved values +# scripts/worktree-env.sh --reset # discard and regenerate + +# ${BASH_SOURCE[0]} is empty when this file is sourced from a non-bash shell +# (zsh is the default on macOS), which would silently resolve the root one level +# too high. Fall back to walking up from the current directory for the marker +# files, so `source scripts/worktree-env.sh` works from any shell. +if [ -n "${BASH_SOURCE:-}" ]; then + WORKTREE_ENV_SCRIPT_PATH="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd -P)" + WORKTREE_ENV_ROOT="$(cd -- "$WORKTREE_ENV_SCRIPT_PATH/.." >/dev/null 2>&1 && pwd -P)" +else + WORKTREE_ENV_ROOT="$(pwd -P)" + while [ "$WORKTREE_ENV_ROOT" != "/" ]; do + if [ -f "$WORKTREE_ENV_ROOT/docker-compose.yaml" ] && [ -f "$WORKTREE_ENV_ROOT/scripts/worktree-env.sh" ]; then + break + fi + WORKTREE_ENV_ROOT="$(dirname "$WORKTREE_ENV_ROOT")" + done + WORKTREE_ENV_SCRIPT_PATH="$WORKTREE_ENV_ROOT/scripts" +fi +if [ ! -f "$WORKTREE_ENV_ROOT/docker-compose.yaml" ]; then + echo "worktree-env: cannot locate the repository root (looked at $WORKTREE_ENV_ROOT)" >&2 +fi +WORKTREE_ENV_FILE="$WORKTREE_ENV_ROOT/config/.env.worktree" + +# True when a TCP port on localhost is already accepting connections. +worktree_env_port_in_use() { + local port="$1" + if command -v nc >/dev/null 2>&1; then + nc -z 127.0.0.1 "$port" >/dev/null 2>&1 && return 0 + fi + # Also catch ports bound by a listener that refuses our probe. + if command -v lsof >/dev/null 2>&1; then + lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1 && return 0 + fi + return 1 +} + +# First free port at or after $1. Gives up after 400 tries rather than spinning. +worktree_env_free_port() { + local candidate="$1" tries=0 + while worktree_env_port_in_use "$candidate"; do + candidate=$((candidate + 1)) + tries=$((tries + 1)) + if [ "$tries" -gt 400 ]; then + echo "worktree-env: no free port found near $1" >&2 + return 1 + fi + done + echo "$candidate" +} + +worktree_env_generate() { + local name offset host_port test_port user pass db test_db user_db user_test_db + + # Compose project names must be lowercase alphanumeric, "_" or "-". + name="$(basename "$WORKTREE_ENV_ROOT" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]/-/g')" + + # Seed the port search from a hash of the absolute worktree path. Hashing (not + # "first free port") means two worktrees whose stacks are both down still tend + # to pick different ports, instead of both grabbing 5432 and colliding later. + offset=$(( $(printf '%s' "$WORKTREE_ENV_ROOT" | cksum | cut -d' ' -f1) % 200 )) + + host_port="$(worktree_env_free_port $((5432 + offset)))" || return 1 + test_port="$(worktree_env_free_port $((54320 + offset)))" || return 1 + + # Credentials and DB names come from the tracked defaults. + user="$(grep -E '^POSTGRES_USER=' "$WORKTREE_ENV_ROOT/config/.env.local" | cut -d= -f2-)" + pass="$(grep -E '^POSTGRES_PASSWORD=' "$WORKTREE_ENV_ROOT/config/.env.local" | cut -d= -f2-)" + db="$(grep -E '^POSTGRES_DB=' "$WORKTREE_ENV_ROOT/config/.env.local" | cut -d= -f2-)" + test_db="$(grep -E '^POSTGRES_TEST_DB=' "$WORKTREE_ENV_ROOT/config/.env.local" | cut -d= -f2-)" + user_db="$(grep -E '^POSTGRES_USER_DB=' "$WORKTREE_ENV_ROOT/config/.env.local" | cut -d= -f2-)" + user_test_db="$(grep -E '^POSTGRES_USER_TEST_DB=' "$WORKTREE_ENV_ROOT/config/.env.local" | cut -d= -f2-)" + + mkdir -p "$WORKTREE_ENV_ROOT/config" + cat > "$WORKTREE_ENV_FILE" <&2 +} + +# Load the override into the environment if it exists. Never creates it. +worktree_env_load() { + if [ -f "$WORKTREE_ENV_FILE" ]; then + set -a + # shellcheck disable=SC1090 + . "$WORKTREE_ENV_FILE" + set +a + fi +} + +# Generate on first use, then load. +worktree_env_ensure() { + [ -f "$WORKTREE_ENV_FILE" ] || worktree_env_generate || return 1 + worktree_env_load +} + +# Populates the WORKTREE_COMPOSE_ARGS array with the arguments `docker compose` +# needs to pin both the project and the env files, so the project name never +# depends on the caller's working directory. +# +# Sets a global array rather than printing, because macOS ships bash 3.2 and +# `mapfile` (bash 4+) is not available to read the output back. +worktree_env_compose_args() { + WORKTREE_COMPOSE_ARGS=(-f "$WORKTREE_ENV_ROOT/docker-compose.yaml" --env-file "$WORKTREE_ENV_ROOT/config/.env.local") + if [ -f "$WORKTREE_ENV_FILE" ]; then + WORKTREE_COMPOSE_ARGS+=(--env-file "$WORKTREE_ENV_FILE") + fi + if [ -n "$COMPOSE_PROJECT_NAME" ]; then + WORKTREE_COMPOSE_ARGS+=(-p "$COMPOSE_PROJECT_NAME") + fi +} + +case "${1:-}" in + --ensure) worktree_env_ensure ;; + --reset) rm -f "$WORKTREE_ENV_FILE"; worktree_env_ensure ;; + --print) + worktree_env_ensure + echo "COMPOSE_PROJECT_NAME=$COMPOSE_PROJECT_NAME" + echo "POSTGRES_HOST_PORT=$POSTGRES_HOST_PORT" + echo "POSTGRES_TEST_PORT=$POSTGRES_TEST_PORT" + ;; + *) worktree_env_load ;; +esac