diff --git a/.gitignore b/.gitignore index ad5dd4e..3dc1fc2 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,9 @@ client/.tls/ # direnv .direnv/ +# Per-worktree port assignment written by `just worktree-adopt` +/.env + .flox # shotput workspace-copy (image-inline clients only; not needed on OpenCode web) diff --git a/Justfile b/Justfile index 34efd3b..c92f3a0 100644 --- a/Justfile +++ b/Justfile @@ -1,5 +1,12 @@ # deckd — common commands +# Load a gitignored ./.env if one exists. `just worktree-adopt` writes one per +# worktree holding that checkout's port assignment, so every recipe below picks +# up the right ports with no env-var juggling. Nothing else uses it, the +# primary checkout doesn't need one, and an explicit env var still wins: +# `DECKD_PORT=9000 just dev`. +set dotenv-load := true + # `just` (no args) lists available recipes. default: @just --list @@ -71,11 +78,14 @@ run-daemon: run-daemon-lan: VLC_HTTP_PASSWORD=dummy deckd --bind 0.0.0.0 --layouts-dir layouts --verbose -# Default ports. Override with DECKD_PORT / VITE_PORT when running multiple -# worktrees side-by-side (each `git worktree` lives on its own checkout but -# still shares the host's port space). +# Default ports, and the knobs that let worktrees coexist. `just worktree-adopt` +# writes all four into a per-checkout ./.env (loaded above); set them by hand +# for a one-off. DECKD_E2E_PORT and DECKD_SMOKE_PORT move the two throwaway +# test daemons, so two worktrees can run `just test-all` at the same time. DECKD_PORT := env_var_or_default("DECKD_PORT", "8765") VITE_PORT := env_var_or_default("VITE_PORT", "5173") +DECKD_E2E_PORT := env_var_or_default("DECKD_E2E_PORT", "8975") +DECKD_SMOKE_PORT := env_var_or_default("DECKD_SMOKE_PORT", "18765") # Kill whatever is bound to the two ports we use: the daemon (default :8765) # and the Vite dev server (default :5173). Handy when a stale daemon still @@ -309,9 +319,11 @@ test-client: # End-to-end smoke test (boots daemon in-process, fires every action # primitive). Uses a stable fixture layout (scripts/smoke_fixtures/) # so shipping-layout edits can't break CI (#77). Pass --layouts-dir to -# point at shipping layouts (or anything else) instead. +# point at shipping layouts (or anything else) instead. Binds DECKD_SMOKE_PORT +# (default :18765, well away from any live daemon) so two worktrees can run +# it concurrently. smoke: - python -u scripts/smoke.py + DECKD_SMOKE_PORT={{DECKD_SMOKE_PORT}} python -u scripts/smoke.py # Check whether this shell can create a uinput scroll device. check-uinput: @@ -395,8 +407,14 @@ watch-focus-once: python -u scripts/watch_focus.py --once # Hit /health. +# +# These four all target DECKD_PORT — i.e. *this* checkout's daemon. Without +# that, deckctl's own default (:8765) would answer from whatever holds the +# default port, which on a machine running an installed deckd service is the +# prod daemon rather than the dev one you just started. To aim at another +# instance deliberately: `DECKD_PORT=8765 just status`. status: - deckctl status + deckctl --port {{DECKD_PORT}} status # Hit /diag (issue #70): one-shot machine-readable snapshot of the # daemon's focus, input, layouts, sessions, and MPRIS state. Open-auth, @@ -405,14 +423,14 @@ status: diag: #!/usr/bin/env bash set -euo pipefail - deckctl diag + deckctl --port {{DECKD_PORT}} diag # Hit /layouts (issue #70): enumeration of loaded layouts and safe # widget summaries (no action bodies). layouts: #!/usr/bin/env bash set -euo pipefail - deckctl layouts + deckctl --port {{DECKD_PORT}} layouts # Hit /metrics (issue #71): Prometheus text-format scrape. Open-auth # and stdlib-only on the server side; pipe into ``head`` or @@ -420,7 +438,7 @@ layouts: metrics: #!/usr/bin/env bash set -euo pipefail - deckctl metrics + deckctl --port {{DECKD_PORT}} metrics # Run the Nix flake checks: builds packages.deckd and the focus-watcher # bundles, evaluates the NixOS + home-manager modules, unit-tests the @@ -429,3 +447,43 @@ metrics: # See docs/GUIDE.md "Nix flake, NixOS, and home-manager". nix-check: nix flake check -L + +# --- Worktrees ------------------------------------------------------------ +# Code needs nothing for `git worktree` (every path resolves from the file's +# own location). What a fresh checkout lacks is the gitignored scaffolding — +# .envrc, .venv, client/node_modules, TLS certs — plus a port assignment that +# doesn't collide with its siblings. See docs/ONBOARDING.md#worktrees-git-worktree. + +# "Adopt" because the worktree usually already exists: an agent harness +# (Paseo, Cursor) or a plain `git worktree add` made it, and this claims it +# afterwards. Assigns free ports -> ./.env, wires .envrc to the primary +# checkout's flox env, copies over gitignored bits worth sharing (TLS certs), +# then runs `just setup`. Idempotent, so it's also the repair command. Pass +# --no-install to skip the slow dependency step, --force to reassign ports. +# +# Make THIS worktree dev-ready: ports, env, dependencies. [--no-install] [--force] +worktree-adopt *args: + @bash scripts/worktree.sh adopt {{args}} + +# Checks port assignment (including collisions with siblings), .envrc, +# toolchain on PATH, venv, and client deps. Every failure prints its fix; +# exits non-zero if the checkout isn't ready. +# +# Why isn't this worktree working? +worktree-doctor: + @bash scripts/worktree.sh doctor + +# Every worktree with its assigned ports and readiness. `*` marks this one. +worktree-list: + @bash scripts/worktree.sh list + +# For harness-created worktrees, run `worktree-adopt` inside the checkout +# instead — this is only for the from-scratch case. +# +# Add a worktree at ../deckd- on a new branch off HEAD, then adopt it. +worktree-create branch *args: + #!/usr/bin/env bash + set -euo pipefail + dir="$(cd "$(git rev-parse --show-toplevel)/.." && pwd)/deckd-{{branch}}" + git worktree add -b "{{branch}}" "$dir" + cd "$dir" && just worktree-adopt {{args}} diff --git a/client/playwright.config.ts b/client/playwright.config.ts index 2d5f3e8..72f6469 100644 --- a/client/playwright.config.ts +++ b/client/playwright.config.ts @@ -6,6 +6,12 @@ import { findChromiumExe } from "./e2e/find-chromium.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); +// Port for the fixture daemon this config boots. Defaults to 8975 (not the +// daemon default 8765) so e2e runs alongside a live dev/user daemon; a +// worktree overrides it via its ./.env so two checkouts can run `just test-all` +// at the same time (see docs/ONBOARDING.md#worktrees-git-worktree). +const e2ePort = Number(process.env.DECKD_E2E_PORT ?? 8975); + export default defineConfig({ testDir: "./e2e", fullyParallel: false, @@ -13,7 +19,7 @@ export default defineConfig({ reporter: [["list"]], timeout: 30000, use: { - baseURL: "http://localhost:8975", + baseURL: `http://localhost:${e2ePort}`, trace: "retain-on-failure", }, projects: [ @@ -27,8 +33,8 @@ export default defineConfig({ ], webServer: { // Copy the repo layouts into a throwaway tmp dir so an e2e save cycle - // never mutates the human-owned YAML. Port 8975 (not the daemon default - // 8765) so e2e can run alongside a live dev/user daemon. + // never mutates the human-owned YAML — suffixed with the port so two + // worktrees running e2e concurrently don't stomp each other's copy. // DECKD_BIN: ./.venv is the plain-uv layout; a flox checkout has no // ./.venv and gets `deckd` from the activated env on PATH. // @@ -44,9 +50,9 @@ export default defineConfig({ // can assert the surface + chrome dot without a session bus or a // real MPRIS player on the runner (see daemon/deckd/__main__.py). command: - 'cd .. && DECKD_BIN=.venv/bin/deckd && [ -x "$DECKD_BIN" ] || DECKD_BIN=deckd; rm -rf /tmp/deckd-e2e-layouts && mkdir /tmp/deckd-e2e-layouts && cp layouts/default.yaml layouts/editor.yaml layouts/mpris.yaml /tmp/deckd-e2e-layouts/ && rm -f client/e2e/.daemon.log && PYTHONUNBUFFERED=1 PYTHONPATH=scripts/no-evdev DECKD_FAKE_INPUT=1 DECKD_FAKE_MPRIS=client/e2e/fixtures/mpris-seed.json "$DECKD_BIN" --layouts-dir /tmp/deckd-e2e-layouts --client-dist client/dist --no-auth --no-focus --port 8975 --verbose > client/e2e/.daemon.log 2>&1', + `cd .. && DECKD_BIN=.venv/bin/deckd && [ -x "$DECKD_BIN" ] || DECKD_BIN=deckd; rm -rf /tmp/deckd-e2e-layouts-${e2ePort} && mkdir /tmp/deckd-e2e-layouts-${e2ePort} && cp layouts/default.yaml layouts/editor.yaml layouts/mpris.yaml /tmp/deckd-e2e-layouts-${e2ePort}/ && rm -f client/e2e/.daemon.log && PYTHONUNBUFFERED=1 PYTHONPATH=scripts/no-evdev DECKD_FAKE_INPUT=1 DECKD_FAKE_MPRIS=client/e2e/fixtures/mpris-seed.json "$DECKD_BIN" --layouts-dir /tmp/deckd-e2e-layouts-${e2ePort} --client-dist client/dist --no-auth --no-focus --port ${e2ePort} --verbose > client/e2e/.daemon.log 2>&1`, cwd: __dirname, - port: 8975, + port: e2ePort, reuseExistingServer: false, timeout: 30000, }, diff --git a/docs/GUIDE.md b/docs/GUIDE.md index a7c6da2..1362bd0 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -108,7 +108,7 @@ just build-client just dev-daemon # listens on http://127.0.0.1:8765, auto-restarts on Python edits ``` -`just dev-daemon` wraps the daemon in the `deckd-dev` supervisor so Python edits hot-reload (YAML hot-reloads either way). For a one-shot `deckd` invocation use `just run-daemon`. Running multiple `git worktree`s side-by-side? Override the default ports with `DECKD_PORT` / `VITE_PORT` (see [docs/ONBOARDING.md](ONBOARDING.md#worktrees-git-worktree)). +`just dev-daemon` wraps the daemon in the `deckd-dev` supervisor so Python edits hot-reload (YAML hot-reloads either way). For a one-shot `deckd` invocation use `just run-daemon`. Running multiple `git worktree`s side-by-side? Run `just worktree-adopt` once inside each new checkout — it assigns non-colliding ports, wires the toolchain to the primary checkout's env, and installs dependencies (see [docs/ONBOARDING.md](ONBOARDING.md#worktrees-git-worktree)). To force a specific platform's setup (e.g. on a CI box): `just setup-linux` or `just setup-macos`. diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index 4d845c8..d2b1c28 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -148,53 +148,149 @@ Key daemon CLI flags (in `daemon/deckd/__main__.py`): ### Worktrees (`git worktree`) -Each `git worktree add` is a fully independent checkout of the repo. Paths -inside the daemon, tests, and scripts are anchored to the file's own -location (`Path(__file__).resolve().parents[N]`), so layouts, fixtures, and -the built client all resolve correctly without any symlinks or rewrites — -no code change is required for worktree support. +Each `git worktree add` is a fully independent checkout. Paths inside the +daemon, tests, and scripts are anchored to the file's own location +(`Path(__file__).resolve().parents[N]`), so layouts, fixtures, and the built +client all resolve correctly with no symlinks or rewrites — **no code change +is required for worktree support.** -The one resource that *is* shared is the host's port space: every worktree -that runs `just dev` defaults to `:8765` (daemon) and `:5173` (Vite), so a -second worktree can't bind the same ports. Override with env vars before -launching: +What a fresh worktree *does* lack is everything git deliberately doesn't carry +across: the gitignored scaffolding (`.envrc`, `.flox/`, `.venv/`, +`client/node_modules/`, `client/.tls/`) and a port assignment that doesn't +collide with its siblings. One command fixes all of it: ```sh -# worktree 1 (defaults) -just dev +cd /path/to/the/new/worktree +just worktree-adopt +``` + +"Adopt", not "create", because the checkout usually already exists — an agent +harness (Paseo, Cursor) or a plain `git worktree add` made it, and this claims +it afterwards. It is idempotent, so it's also the repair command. It: + +1. assigns the lowest free port offset and writes it to a gitignored `./.env` + (the primary prefers offset 0, but moves off it if those ports are taken); +2. writes an `.envrc` that resolves the **primary** checkout's flox + environment (only if the primary itself uses direnv/flox — the repo doesn't + prescribe either), and runs `direnv allow`; +3. copies gitignored-but-shareable files over, currently `client/.tls` + (host-wide certs that otherwise cost a `sudo` prompt per worktree); +4. runs `just setup` — pass `--no-install` to skip that and do it yourself. -# worktree 2 — pick free ports and keep them consistent across all recipes -DECKD_PORT=8766 VITE_PORT=5174 just dev +Then: + +```sh +just worktree-doctor # why isn't this worktree working? every failure prints its fix +just worktree-list # all worktrees, their ports, and whether they're ready +just worktree-create B # git worktree add ../deckd-B on a new branch, then adopt it ``` -The dev recipes read these vars and pass them to both halves: - -- `DECKD_PORT` is forwarded as `deckd-dev`'s `--port`; `dev-daemon`, - `dev-daemon-lan`, `dev`, and `dev-lan` all honour it. -- `VITE_PORT` is forwarded as Vite's `--port`. When it's been overridden - the recipe drops `--strictPort` so Vite falls through to the next free - port instead of failing; it also sets `DECKD_UPSTREAM` so Vite's - `/ws`/`/health` proxy reaches the *current* worktree's daemon. -- `just kill` only tears down the *current* worktree's ports, so two - worktrees running side-by-side won't take each other down. - -Caveats: - -- **`just install-service` should only be run from your main checkout.** - It writes the literal `$(pwd)` into the systemd unit / launchd plist; - doing it from a feature worktree pins the service to a worktree that - will eventually be removed. -- **Live MPRIS / focus smoke tests** (`just smoke-mpris`, `just - smoke-focus`) hit the real session bus, so two worktrees can't run - them simultaneously. +#### Ports + +The one genuinely shared resource is the host's port space. Every checkout +gets an offset applied to all four bases at once, so its ports stay mentally +grouped: + +| offset | `DECKD_PORT` | `VITE_PORT` | `DECKD_E2E_PORT` | `DECKD_SMOKE_PORT` | who | +| --- | --- | --- | --- | --- | --- | +| 0 | 8765 | 5173 | 8975 | 18765 | the primary checkout, *if the defaults are free* | +| 1 | 8766 | 5174 | 8976 | 18766 | first adopted checkout | +| 2 | 8767 | 5175 | 8977 | 18767 | second, and so on | + +The primary prefers offset 0 and normally needs no `.env` at all, so on a +machine with no installed deckd nothing about the main checkout changes. It +does **not** own offset 0 though: if something already holds `:8765` — almost +always an installed deckd service — `worktree-adopt` moves the primary to a +free offset like any other checkout, and says so. Delete its `.env` and +re-adopt to move back once the port frees up. + +An offset is only free when *all four* of its ports are, so a stray process on +one port pushes the whole group along rather than producing a half-working +checkout. + +`.env` is loaded automatically by every recipe (`set dotenv-load` in the +Justfile), so `just dev`, `just kill`, `just smoke`, and `just test-all` all +target the current checkout with no env-var juggling. An explicit variable +still wins for a one-off: `DECKD_PORT=9000 just dev`. + +- `DECKD_PORT` becomes `deckd-dev`'s `--port` (`dev-daemon`, `dev-daemon-lan`, + `dev`, `dev-lan`) — and `deckctl`'s `--port` in `just status`, `diag`, + `layouts`, and `metrics`, so those report on *this* checkout's daemon rather + than whatever holds the default port. +- `VITE_PORT` becomes Vite's `--port`; when it differs from 5173 the recipe + drops `--strictPort` so Vite falls through if the port is busy, and sets + `DECKD_UPSTREAM` so the `/ws` + `/health` proxy reaches *this* checkout's + daemon. +- `DECKD_E2E_PORT` moves the Playwright fixture daemon and its throwaway + layouts dir; `DECKD_SMOKE_PORT` moves the in-process smoke server. Together + they let two checkouts run `just test-all` simultaneously. +- `just kill` only tears down the current checkout's ports. + +#### Running dev alongside an installed deckd + +A machine can run an installed deckd service (systemd/launchd/home-manager) +and any number of dev instances at once. What's isolated, and what isn't: + +**Isolated, no action needed.** Layouts — the service reads +`~/.config/deckd/layouts`, dev checkouts read their own `./layouts`, so an +editor save in a dev instance can't touch the service's. Client state — each +port is a distinct browser origin, so every instance gets its own PWA storage +and service worker. The password file (`~/.config/deckd/password`) *is* +shared, which is a convenience rather than a conflict: one password opens +every instance. + +**Handled by the offsets.** Ports, including the primary checkout, per above. + +**Not isolated, and can't be.** These act on shared session state, so every +running daemon competes: + +- **Input injection.** Each daemon opens its own uinput device (all named + `deckd`) and injects into whatever window currently has focus. Press a + button on the service's client and on a dev client and the target app + receives both. +- **MPRIS transport and `dbus:` actions.** Same story — they drive the + session's real players and services. +- **KDE focus (`org.deckd.Focus`).** On KDE the *daemon* owns the bus name, + and it requests it with `NameFlag.REPLACE_EXISTING` — so the last daemon to + start silently takes focus pushes away from every other one, including the + installed service. GNOME is unaffected: there the Shell extension owns the + name and daemons only call it, so any number coexist. + +In practice: run as many daemons as you like, but only drive *one* client at a +time, and on KDE expect focus-dependent behaviour to follow the most recently +started daemon. + +#### Toolchain: shared env, per-worktree venv + +A worktree has no `.flox/` of its own, and direnv's stdlib `use flox` requires +a local one — so the generated `.envrc` calls `flox activate -d ` +directly. flox leaves `$PWD` alone, so the toolchain resolves from the primary +while this worktree's own `.venv` is the one that gets used. That split is +deliberate and load-bearing: + +- **Toolchain is shared** (python, node) — nothing to rebuild per worktree. +- **The venv is not.** `uv pip install -e .` bakes an absolute path into the + editable install, so a shared venv would silently point `deckd` at whichever + worktree installed last. + +The manifest's `[profile]` hook is what exports `$PWD/.venv/bin`, but flox +only sources it for an **interactive** shell — direnv's non-interactive env +dump never carries it. So the generated `.envrc` adds `.venv/bin` to `PATH` +(and exports `VIRTUAL_ENV`) itself. Without that, `just dev-daemon` and the +`deckctl` recipes fail with `deckd-dev: command not found` in an activated +worktree, even though `.venv/bin/deckd-dev` exists. If you hand-edit `.envrc`, +keep that block. + +Caveats that remain: + +- **`just install-service` should only be run from your main checkout.** It + writes the literal `$(pwd)` into the systemd unit / launchd plist; from a + feature worktree it pins the service to a checkout that will be removed. +- **Live MPRIS / focus smoke tests** (`just smoke-mpris`, `just smoke-focus`) + hit the real session bus, so two worktrees can't run them at once. - **`uv.lock` is per-repo, not per-worktree.** A `uv pip install` in one - worktree edits the lockfile that all worktrees share; if you're - intentionally diverging dependencies, isolate with a worktree-local - venv (`uv venv --python 3.11 .venv`) and commit changes deliberately. -- **Each worktree needs its own `.venv/`** (run `just setup` per - worktree); `just test-all` only prepends `./.venv/bin` when one - exists, so a worktree without one will fall back to whatever Python - is on PATH (flox's, or the active interpreter). + worktree edits the lockfile every worktree shares; if you're intentionally + diverging dependencies, commit the change deliberately. ## Verification ladder diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 7df3a99..eb51fda 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -82,6 +82,18 @@ deckctl [--host HOST] [--port PORT] [--password PASSWORD] |----------|---------| | `DECKD_PASSWORD` | Passed by `vite.config.ts` proxy to the daemon. Also consumed by local scripts. | | `VITE_BASE_PATH` | Base URL when deploying to a subdirectory (e.g. GitHub Pages). | +| `DECKD_UPSTREAM` | Daemon origin the Vite dev-server proxy forwards `/ws` and `/health` to (default `http://127.0.0.1:8765`). | + +### Development ports + +Read by the Justfile, and written per-worktree into a gitignored `./.env` by `just worktree-adopt` (see [ONBOARDING.md](ONBOARDING.md#worktrees-git-worktree)). An explicit variable overrides the `.env`. The primary checkout also gets a `.env` if its defaults are already taken — e.g. by an installed deckd service. + +| Variable | Default | Purpose | +|----------|---------|---------| +| `DECKD_PORT` | `8765` | Daemon port for every `dev-*` / `run-*` recipe, `deckctl` in `status` / `diag` / `layouts` / `metrics`, and the pair `just kill` frees. | +| `VITE_PORT` | `5173` | Vite dev-server port. When overridden, `--strictPort` is dropped so Vite can fall through. | +| `DECKD_E2E_PORT` | `8975` | Playwright fixture daemon, so two worktrees can run `just test-all` at once. | +| `DECKD_SMOKE_PORT` | `18765` | In-process `just smoke` server, so two worktrees can smoke-test at once. | ## Authentication @@ -162,6 +174,10 @@ Primary development operations. Run `just` (no args) to list all available recip | `just watch-focus` | Print active-app changes in real time. | | `just install-focus-extension` | Install the GNOME Shell focus extension. | | `just install-focus-kwin` | Install the KDE Plasma KWin focus script. | +| `just worktree-adopt` | Make the current `git worktree` checkout dev-ready: ports, `.envrc`, shared gitignored files, `just setup`. Idempotent. | +| `just worktree-doctor` | Diagnose a worktree that is not working; every failure prints its fix. | +| `just worktree-list` | All worktrees with their assigned ports and readiness. | +| `just worktree-create BRANCH` | `git worktree add ../deckd-BRANCH` on a new branch, then adopt it. | ## Shipped behavior, limitations, and planned work diff --git a/scripts/smoke.py b/scripts/smoke.py index b57d72c..04267b1 100644 --- a/scripts/smoke.py +++ b/scripts/smoke.py @@ -12,6 +12,7 @@ import argparse import asyncio import json +import os import sys from pathlib import Path @@ -54,32 +55,40 @@ def close(self) -> None: pass +# Port for the throwaway in-process server. Fixed high port by default so the +# smoke run never touches a live daemon; overridable so two worktrees can run +# `just smoke` (or `just test-all`) at once — see +# docs/ONBOARDING.md#worktrees-git-worktree. +PORT = int(os.environ.get("DECKD_SMOKE_PORT", "18765")) +BASE = f"http://127.0.0.1:{PORT}" + + async def main(layouts_dir: Path) -> None: print(f"starting server (layouts: {layouts_dir})...", flush=True) server = Server( layouts_dir=layouts_dir, host="127.0.0.1", - port=18765, + port=PORT, scroll=ScrollController(FakeScrollSink()), ) runner = web.AppRunner(server.app) await runner.setup() - site = web.TCPSite(runner, "127.0.0.1", 18765) + site = web.TCPSite(runner, "127.0.0.1", PORT) await site.start() print("server up", flush=True) try: print("hitting health...", flush=True) async with ClientSession() as http: - async with http.get("http://127.0.0.1:18765/health") as r: + async with http.get(f"{BASE}/health") as r: body = await r.json() print("health:", body, flush=True) # /reload - async with http.post("http://127.0.0.1:18765/reload") as r: + async with http.post(f"{BASE}/reload") as r: print("reload:", await r.json(), flush=True) print("connecting ws...", flush=True) - async with websockets.connect("ws://127.0.0.1:18765/ws", open_timeout=2, close_timeout=2) as ws: + async with websockets.connect(f"ws://127.0.0.1:{PORT}/ws", open_timeout=2, close_timeout=2) as ws: print("ws open", flush=True) first = json.loads(await asyncio.wait_for(ws.recv(), timeout=2)) print("first:", first["type"], [w["id"] for w in first["widgets"]]) diff --git a/scripts/worktree.sh b/scripts/worktree.sh new file mode 100755 index 0000000..07aa19f --- /dev/null +++ b/scripts/worktree.sh @@ -0,0 +1,388 @@ +#!/usr/bin/env bash +# Make a `git worktree` checkout of deckd dev-ready, and diagnose one that +# isn't. Driven by `just worktree-adopt` / `worktree-doctor` / `worktree-list`. +# +# Why this exists: the daemon, tests and scripts already resolve their paths +# from the file's own location, so *code* needs nothing for worktrees. What a +# fresh worktree lacks is everything git deliberately doesn't carry across: +# +# - the host's port space is shared, so two worktrees both default to +# :8765/:5173/:8975 and collide; +# - `.envrc`, `.flox/`, `.venv/`, `client/node_modules/` and `client/.tls/` +# are all gitignored, so a new checkout has no toolchain, no venv, no +# dependencies and no TLS cert. +# +# `adopt` fixes all of that in one idempotent pass. The name follows the +# convention in the sibling monorepo: worktrees created by an agent harness +# (Paseo, Cursor) or a bare `git worktree add` get *adopted* after the fact, +# rather than being created by a bespoke wrapper. +# +# Bash 3.2 compatible (macOS ships 3.2): no associative arrays, no ${x,,}. +set -euo pipefail + +# Port bases. Offset 0 is the primary checkout, so its ports are exactly +# today's defaults and nothing about the main checkout changes. Linked +# worktrees take the lowest free offset >= 1, applied to every base at once so +# a checkout's ports stay mentally grouped (8766/5174/8976/18766). Offset 0 is +# only the primary's if nothing else already holds it — an installed deckd +# service on :8765 displaces the main checkout like any other collision. +BASE_DECKD=8765 +BASE_VITE=5173 +BASE_E2E=8975 +BASE_SMOKE=18765 +MAX_OFFSET=64 + +# Gitignored paths worth carrying from the primary checkout into a new +# worktree. Certs are the expensive one: `just dev-client-tailscale` +# provisions them with sudo, and they're host-wide, so re-provisioning per +# worktree is pure friction. Overridable by a .worktreeinclude file. +DEFAULT_INCLUDE="client/.tls" + +say() { printf '%s\n' "$*"; } +warn() { printf 'warn: %s\n' "$*" >&2; } +die() { printf 'error: %s\n' "$*" >&2; exit 1; } + +repo_root() { git rev-parse --show-toplevel; } + +# First entry of `git worktree list` is always the main working tree. +primary_root() { git worktree list --porcelain | awk '/^worktree /{print $2; exit}'; } + +all_worktrees() { git worktree list --porcelain | awk '/^worktree /{print $2}'; } + +is_primary() { [ "$(repo_root)" = "$(primary_root)" ]; } + +# Read one KEY=VALUE out of a dotenv file. Empty output when absent. +env_value() { + local file="$1" key="$2" + [ -f "$file" ] || return 0 + sed -n "s/^[[:space:]]*${key}[[:space:]]*=[[:space:]]*\([^[:space:]#]*\).*/\1/p" "$file" | tail -n1 +} + +# The offset a checkout has written into its own .env, or empty. Distinct from +# offset_of below: allocation has to know whether the *primary* has actually +# claimed an offset, not just whether it implicitly owns 0. +env_offset() { + local port + port="$(env_value "$1/.env" DECKD_PORT)" + [ -n "$port" ] && echo $(( port - BASE_DECKD )) + return 0 +} + +# The offset a checkout has already claimed, or empty if it has claimed none. +# The primary implicitly owns offset 0 whether or not it has a .env, so +# siblings never allocate on top of it. +offset_of() { + local dir="$1" port + port="$(env_offset "$dir")" + if [ -n "$port" ]; then + echo "$port" + return 0 + fi + # Must not leak a non-zero status: callers assign this in a command + # substitution, where `set -e` treats a bare failure as fatal — which + # would abort `doctor` at the first unassigned worktree it looked at. + [ "$dir" = "$(primary_root)" ] && echo 0 + return 0 +} + +# True when nothing is listening on the port. `ss` on Linux, `lsof` on macOS +# (the Justfile's `kill` recipe already depends on lsof). With neither, skip +# the liveness check — the .env scan below is the primary mechanism and this +# is only a guard against ports held by non-worktree processes. +port_free() { + local p="$1" + # Test seam: a space-separated list of ports to treat as occupied. When + # set (even to ""), it replaces the live check entirely, so the allocator + # can be exercised without binding real sockets — and so the tests don't + # depend on what happens to be listening on the developer's machine. + if [ -n "${DECKD_WORKTREE_BUSY_PORTS+set}" ]; then + case " $DECKD_WORKTREE_BUSY_PORTS " in *" $p "*) return 1 ;; esac + return 0 + fi + if command -v ss >/dev/null 2>&1; then + ! ss -ltnH "sport = :$p" 2>/dev/null | grep -q . + elif command -v lsof >/dev/null 2>&1; then + ! lsof -ti "tcp:$p" >/dev/null 2>&1 + else + return 0 + fi +} + +# Every port an offset would claim is unbound. Offset 0 is checked like any +# other: on a machine that also runs an installed deckd service, :8765 is +# already taken and even the primary checkout has to move. +offset_free() { + local o="$1" + port_free $(( BASE_DECKD + o )) \ + && port_free $(( BASE_VITE + o )) \ + && port_free $(( BASE_E2E + o )) \ + && port_free $(( BASE_SMOKE + o )) +} + +# Pick this worktree's offset: an already-written .env wins (adopt is +# idempotent and must never renumber a worktree out from under a running +# daemon), then the primary's implicit 0, then the lowest offset not claimed +# by a sibling .env and whose three ports are all unbound. +allocate_offset() { + local self existing other o claimed used + self="$(repo_root)" + # env_offset, not offset_of: the primary's implicit 0 must not short-circuit + # this, or it could never be moved off an occupied :8765. + existing="$(env_offset "$self")" + if [ -n "$existing" ]; then echo "$existing"; return 0; fi + if is_primary && offset_free 0; then echo 0; return 0; fi + + claimed="" + for other in $(all_worktrees); do + [ "$other" = "$self" ] && continue + o="$(offset_of "$other")" + [ -n "$o" ] && claimed="$claimed $o" + done + + o=1 + while [ "$o" -le "$MAX_OFFSET" ]; do + used=0 + case " $claimed " in *" $o "*) used=1 ;; esac + if [ "$used" -eq 0 ] && offset_free "$o"; then + echo "$o"; return 0 + fi + o=$(( o + 1 )) + done + die "no free port offset below $MAX_OFFSET; clean up stale worktrees or daemons" +} + +write_env() { + local offset="$1" root="$2" + cat > "$root/.env" < "$root/.envrc" < .env" + say " delete .env and re-adopt once :$BASE_DECKD is free to go back to the defaults" + fi + else + offset="$(allocate_offset)" + write_env "$offset" "$root" + say "ports: deckd :$(( BASE_DECKD + offset )) vite :$(( BASE_VITE + offset )) e2e :$(( BASE_E2E + offset )) -> .env" + + # Only offer direnv when the primary is set up for it; the repo + # doesn't prescribe direnv or flox, and .envrc is gitignored. + if [ -f "$primary/.envrc" ] || [ -d "$primary/.flox" ]; then + if [ -f "$root/.envrc" ] && [ "$force" -eq 0 ]; then + say " .envrc already present, left alone (--force to rewrite)" + else + write_envrc "$root" "$primary" + say " wrote .envrc -> flox env from $primary" + command -v direnv >/dev/null 2>&1 && direnv allow "$root" 2>/dev/null || true + fi + fi + copy_included "$root" "$primary" + fi + + if [ "$install" -eq 1 ]; then + say "installing dependencies (\`just setup\`) — pass --no-install to skip" + ( cd "$root" && just setup ) + else + say "skipped dependency install; run \`just setup\` in this worktree before testing" + fi + say "adopted. \`just worktree-doctor\` verifies it." +} + +ok() { printf ' ok %s\n' "$*"; } +bad() { printf ' FAIL %s\n' "$*"; DOCTOR_FAILED=1; } +note() { printf ' warn %s\n' "$*"; } + +cmd_doctor() { + local root primary offset other o self_o + DOCTOR_FAILED=0 + root="$(repo_root)" + primary="$(primary_root)" + say "worktree: $root ($(git rev-parse --abbrev-ref HEAD))" + + if [ -f "$root/.env" ]; then + ok "ports: deckd :$(env_value "$root/.env" DECKD_PORT)" \ + "vite :$(env_value "$root/.env" VITE_PORT)" \ + "e2e :$(env_value "$root/.env" DECKD_E2E_PORT)" \ + "smoke :$(env_value "$root/.env" DECKD_SMOKE_PORT)" + elif is_primary; then + # No .env means this checkout is running on the bare defaults, which is + # only correct if nothing else already holds them. + if offset_free 0; then + ok "primary checkout — uses the default ports" + else + bad "primary checkout, but :$BASE_DECKD is already in use (installed service?). Fix: just worktree-adopt" + fi + else + bad "no .env — this worktree shares :$BASE_DECKD/:$BASE_VITE with the primary. Fix: just worktree-adopt" + fi + + if ! is_primary; then + # Cross-check for two worktrees on the same offset (hand-edited .env, + # or a worktree adopted while a sibling was mid-adopt). + self_o="$(offset_of "$root")" + if [ -n "$self_o" ]; then + for other in $(all_worktrees); do + [ "$other" = "$root" ] && continue + o="$(offset_of "$other")" + [ "$o" = "$self_o" ] && bad "port offset $self_o also claimed by $other. Fix: just worktree-adopt --force" + done + fi + if [ -f "$root/.envrc" ]; then + ok ".envrc present" + elif [ -f "$primary/.envrc" ] || [ -d "$primary/.flox" ]; then + note "no .envrc — the primary uses direnv/flox but this worktree won't auto-activate" + fi + fi + + command -v just >/dev/null 2>&1 && ok "just on PATH" || bad "just not on PATH" + if command -v uv >/dev/null 2>&1; then ok "uv on PATH"; else + bad "uv not on PATH — \`just setup\` can't build the venv. Fix: activate the env (direnv/flox) first" + fi + command -v node >/dev/null 2>&1 && ok "node on PATH ($(node --version))" || bad "node not on PATH" + + if [ -x "$root/.venv/bin/deckd" ]; then + ok ".venv present with deckd installed" + elif [ -d "$root/.venv" ]; then + bad ".venv exists but has no deckd entry point. Fix: just setup" + elif command -v deckd >/dev/null 2>&1; then + note "no ./.venv; deckd resolves from the active env ($(command -v deckd))" + else + bad "no ./.venv and no deckd on PATH. Fix: just setup" + fi + + [ -d "$root/client/node_modules" ] && ok "client/node_modules present" \ + || bad "no client/node_modules. Fix: just setup" + + if [ "$DOCTOR_FAILED" -eq 1 ]; then + say "not ready." + exit 1 + fi + say "ready." +} + +cmd_list() { + local root self o dport vport state branch + self="$(repo_root)" + printf '%-2s %-36s %-22s %-6s %-6s %s\n' "" BRANCH PATH DECKD VITE STATE + for root in $(all_worktrees); do + o="$(offset_of "$root")" + if [ -n "$o" ]; then + dport="$(( BASE_DECKD + o ))" + vport="$(( BASE_VITE + o ))" + else + dport="-" + vport="-" + fi + state="not adopted" + if [ -d "$root/client/node_modules" ] && { [ -d "$root/.venv" ] || [ "$root" = "$(primary_root)" ]; }; then + state="ready" + elif [ -n "$o" ]; then + state="ports only" + fi + branch="$(git -C "$root" rev-parse --abbrev-ref HEAD 2>/dev/null || echo '?')" + printf '%-2s %-36s %-22s %-6s %-6s %s\n' \ + "$([ "$root" = "$self" ] && echo '*' || echo ' ')" \ + "$branch" "$(basename "$root")" "$dport" "$vport" "$state" + done +} + +case "${1:-}" in + ports) shift; cmd_ports "$@" ;; + adopt) shift; cmd_adopt "$@" ;; + doctor) shift; cmd_doctor "$@" ;; + list) shift; cmd_list "$@" ;; + *) die "usage: scripts/worktree.sh {adopt [--no-install] [--force] | doctor | list | ports}" ;; +esac diff --git a/tests/test_codegen_protocol.py b/tests/test_codegen_protocol.py index 3fa623b..debcce1 100644 --- a/tests/test_codegen_protocol.py +++ b/tests/test_codegen_protocol.py @@ -22,6 +22,8 @@ import sys from pathlib import Path +import pytest + REPO_ROOT = Path(__file__).resolve().parents[1] SCRIPT = REPO_ROOT / "scripts" / "codegen_protocol_ts.py" @@ -164,27 +166,38 @@ def test_generated_output_is_parseable_typescript() -> None: protocol.ts; we wrap the file in a tiny shim that declares those stubs so the check exercises the emitter, not cross-file resolution. + + Invoke the *pinned* compiler from client/node_modules rather than + bare ``npx tsc``: npx silently downloads the newest published + TypeScript when the client deps aren't installed, so the Python-only + CI job was typechecking against whatever npm shipped that day. That + is how TS5112 (new in 5.9 — "tsconfig.json is present but will not be + loaded if files are specified on commandline") broke this test + without a single line of the emitter changing. Skip instead when the + toolchain isn't there; the full `test` job always has it. """ import tempfile + tsc = REPO_ROOT / "client" / "node_modules" / ".bin" / "tsc" + if not tsc.exists(): + pytest.skip("client toolchain not installed; run `npm ci` in client/") + out = _run_codegen() shim = "type Icon = unknown;\n" # stub for the layouts-layer Icon - with tempfile.NamedTemporaryFile( - mode="w", suffix=".ts", prefix="protocol.generated.", delete=False - ) as f: - f.write(shim + out) - ts_path = Path(f.name) - try: + with tempfile.TemporaryDirectory() as tmpdir: + # Run from a directory with no tsconfig.json in it. With files named + # on the commandline tsc ignores the config anyway, and from 5.9 on it + # errors out rather than just ignoring it. + ts_path = Path(tmpdir) / "protocol.generated.check.ts" + ts_path.write_text(shim + out) result = subprocess.run( - ["npx", "tsc", "--noEmit", "--skipLibCheck", + [str(tsc), "--noEmit", "--skipLibCheck", "--strict", "--target", "es2020", "--module", "esnext", "--moduleResolution", "bundler", str(ts_path)], capture_output=True, text=True, - cwd=REPO_ROOT / "client", + cwd=tmpdir, ) assert result.returncode == 0, ( f"generated TS failed to typecheck:\n{result.stdout}\n{result.stderr}" ) - finally: - ts_path.unlink(missing_ok=True) \ No newline at end of file diff --git a/tests/test_worktree_setup.py b/tests/test_worktree_setup.py new file mode 100644 index 0000000..cadfd55 --- /dev/null +++ b/tests/test_worktree_setup.py @@ -0,0 +1,282 @@ +"""``scripts/worktree.sh`` port allocation and diagnosis, over real worktrees. + +Worktree support is mostly *absence* management: a fresh ``git worktree`` has +no ``.env``, no venv and no node_modules, and every checkout defaults to the +same ports. The allocator is the part with logic worth pinning, so these tests +build actual git worktrees in a tmp dir and drive the script against them. + +``adopt`` is always run with ``--no-install`` here: the dependency step is +``just setup`` (uv + npm), which needs network and minutes. What's under test +is the port bookkeeping around it. +""" +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).parent.parent +SCRIPT = REPO_ROOT / "scripts" / "worktree.sh" + +BASE_DECKD = 8765 +BASE_VITE = 5173 +BASE_E2E = 8975 +BASE_SMOKE = 18765 + + +def _git(cwd: Path, *args: str) -> str: + return subprocess.run( + ["git", *args], cwd=cwd, check=True, capture_output=True, text=True + ).stdout + + +def _run( + cwd: Path, *args: str, busy: str = "" +) -> subprocess.CompletedProcess[str]: + """Drive the script with a pinned view of which ports are occupied. + + Without ``DECKD_WORKTREE_BUSY_PORTS`` the allocator probes real sockets, + so results would depend on what happens to be listening on the machine + running the suite — a developer with a deckd on :8765 would see different + offsets than CI. Default to "nothing is busy" and let individual tests + declare otherwise. + """ + return subprocess.run( + ["bash", str(SCRIPT), *args], + cwd=cwd, + capture_output=True, + text=True, + env={**os.environ, "DECKD_WORKTREE_BUSY_PORTS": busy}, + ) + + +def _dotenv(path: Path) -> dict[str, str]: + out: dict[str, str] = {} + for line in path.read_text().splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, _, value = line.partition("=") + out[key.strip()] = value.strip() + return out + + +@pytest.fixture +def primary(tmp_path: Path) -> Path: + """A throwaway repo standing in for the primary checkout.""" + root = tmp_path / "deckd" + root.mkdir() + _git(root, "init", "-q", "-b", "main") + _git(root, "config", "user.email", "test@example.invalid") + _git(root, "config", "user.name", "test") + (root / "README.md").write_text("stub\n") + _git(root, "add", "README.md") + _git(root, "commit", "-qm", "init") + return root + + +def _add_worktree(primary: Path, name: str) -> Path: + path = primary.parent / name + _git(primary, "worktree", "add", "-q", "-b", name, str(path)) + return path + + +def test_primary_keeps_the_default_ports(primary: Path) -> None: + """The main checkout must behave exactly as it did before worktree support: + no .env, no renumbering, :8765/:5173 as always.""" + result = _run(primary, "adopt", "--no-install") + assert result.returncode == 0, result.stderr + assert not (primary / ".env").exists() + assert "primary checkout" in result.stdout + + +def test_primary_moves_off_an_occupied_default_port(primary: Path) -> None: + """The primary prefers offset 0 but doesn't own it. On a machine that also + runs an installed deckd service, :8765 is already taken before any dev + daemon starts, and the main checkout has to move like anyone else — + otherwise `just dev` there just fails on address-in-use forever.""" + result = _run(primary, "adopt", "--no-install", busy=str(BASE_DECKD)) + assert result.returncode == 0, result.stderr + + assert _dotenv(primary / ".env")["DECKD_PORT"] == str(BASE_DECKD + 1) + assert "already taken" in result.stdout + + +def test_primary_offset_leaves_room_for_worktrees(primary: Path) -> None: + """A displaced primary claims its offset like any other checkout, so + siblings adopted afterwards must route around it rather than collide.""" + _run(primary, "adopt", "--no-install", busy=str(BASE_DECKD)) + wt = _add_worktree(primary, "feature-a") + _run(wt, "adopt", "--no-install", busy=str(BASE_DECKD)) + + assert _dotenv(primary / ".env")["DECKD_PORT"] == str(BASE_DECKD + 1) + assert _dotenv(wt / ".env")["DECKD_PORT"] == str(BASE_DECKD + 2) + + +def test_doctor_flags_a_primary_on_an_occupied_default_port(primary: Path) -> None: + result = _run(primary, "doctor", busy=str(BASE_DECKD)) + assert result.returncode == 1 + assert "already in use" in result.stdout + assert "just worktree-adopt" in result.stdout + + +def test_adopt_skips_offsets_whose_ports_are_taken(primary: Path) -> None: + """A port can be held by something that isn't a worktree at all — a stray + daemon, an unrelated dev server. Offsets are only free if all four of + their ports are.""" + wt = _add_worktree(primary, "feature-a") + # Offset 1 is blocked by its smoke port alone; offset 2 by its Vite port. + busy = f"{BASE_SMOKE + 1} {BASE_VITE + 2}" + assert _run(wt, "adopt", "--no-install", busy=busy).returncode == 0 + assert _dotenv(wt / ".env")["DECKD_PORT"] == str(BASE_DECKD + 3) + + +def test_adopt_assigns_the_first_free_offset(primary: Path) -> None: + wt = _add_worktree(primary, "feature-a") + assert _run(wt, "adopt", "--no-install").returncode == 0 + + env = _dotenv(wt / ".env") + assert env == { + "DECKD_PORT": str(BASE_DECKD + 1), + "VITE_PORT": str(BASE_VITE + 1), + "DECKD_E2E_PORT": str(BASE_E2E + 1), + "DECKD_SMOKE_PORT": str(BASE_SMOKE + 1), + } + + +def test_siblings_never_collide(primary: Path) -> None: + """The whole point: three checkouts, three disjoint port sets.""" + worktrees = [_add_worktree(primary, f"feature-{n}") for n in "abc"] + for wt in worktrees: + assert _run(wt, "adopt", "--no-install").returncode == 0 + + assigned = [_dotenv(wt / ".env")["DECKD_PORT"] for wt in worktrees] + assert sorted(assigned) == [str(BASE_DECKD + n) for n in (1, 2, 3)] + # ...and none of them landed on the primary's. + assert str(BASE_DECKD) not in assigned + + +def test_adopt_is_idempotent(primary: Path) -> None: + """Re-adopting must not renumber a worktree — a running daemon, a phone + bookmarked to the Vite port, and `just kill` all depend on stability.""" + wt = _add_worktree(primary, "feature-a") + _run(wt, "adopt", "--no-install") + first = _dotenv(wt / ".env") + + # A sibling appears and takes the next slot; re-adopting must not shuffle. + other = _add_worktree(primary, "feature-b") + _run(other, "adopt", "--no-install") + assert _run(wt, "adopt", "--no-install").returncode == 0 + + assert _dotenv(wt / ".env") == first + assert _dotenv(other / ".env")["DECKD_PORT"] != first["DECKD_PORT"] + + +def test_force_reassigns(primary: Path) -> None: + """--force is the repair path for a hand-edited or duplicated .env.""" + wt = _add_worktree(primary, "feature-a") + _run(wt, "adopt", "--no-install") + (wt / ".env").write_text( + f"DECKD_PORT={BASE_DECKD + 9}\nVITE_PORT={BASE_VITE + 9}\n" + f"DECKD_E2E_PORT={BASE_E2E + 9}\n" + ) + assert _run(wt, "adopt", "--no-install", "--force").returncode == 0 + assert _dotenv(wt / ".env")["DECKD_PORT"] == str(BASE_DECKD + 1) + + +def test_ports_reports_without_writing(primary: Path) -> None: + wt = _add_worktree(primary, "feature-a") + result = _run(wt, "ports") + assert result.returncode == 0 + assert f"DECKD_PORT={BASE_DECKD + 1}" in result.stdout + assert not (wt / ".env").exists() + + +def test_doctor_flags_an_unadopted_worktree(primary: Path) -> None: + wt = _add_worktree(primary, "feature-a") + result = _run(wt, "doctor") + assert result.returncode == 1 + assert "no .env" in result.stdout + assert "just worktree-adopt" in result.stdout + assert "not ready." in result.stdout + + +def test_doctor_flags_duplicate_offsets(primary: Path) -> None: + """Two worktrees on the same ports is the failure worktree support exists + to prevent, so the doctor has to name it rather than just report deps.""" + a, b = (_add_worktree(primary, f"feature-{n}") for n in "ab") + for wt in (a, b): + _run(wt, "adopt", "--no-install") + (b / ".env").write_text((a / ".env").read_text()) + + result = _run(b, "doctor") + assert result.returncode == 1 + assert "also claimed by" in result.stdout + assert "--force" in result.stdout + + +def test_list_shows_every_worktree_with_its_state(primary: Path) -> None: + wt = _add_worktree(primary, "feature-a") + _run(wt, "adopt", "--no-install") + + result = _run(primary, "list") + assert result.returncode == 0 + assert "main" in result.stdout + assert "feature-a" in result.stdout + assert str(BASE_DECKD + 1) in result.stdout + # No deps installed anywhere, so nothing claims to be ready. + assert "ready" not in result.stdout + + +def test_adopt_copies_gitignored_extras(primary: Path) -> None: + """TLS certs cost a sudo prompt to provision and are host-wide, so a new + worktree should inherit the primary's rather than re-minting them.""" + tls = primary / "client" / ".tls" + tls.mkdir(parents=True) + (tls / "host.crt").write_text("cert\n") + + wt = _add_worktree(primary, "feature-a") + assert _run(wt, "adopt", "--no-install").returncode == 0 + assert (wt / "client" / ".tls" / "host.crt").read_text() == "cert\n" + + +def test_adopt_wires_envrc_to_the_primary_flox_env(primary: Path) -> None: + """A worktree has no .flox/ of its own; direnv's `use flox` requires one, + so the generated .envrc resolves the primary's env by path instead.""" + (primary / ".flox").mkdir() + wt = _add_worktree(primary, "feature-a") + assert _run(wt, "adopt", "--no-install").returncode == 0 + + envrc = (wt / ".envrc").read_text() + assert f'flox activate -d "{primary}"' in envrc + assert "use flox" in envrc # local .flox/ still wins if one appears + + +def test_envrc_adds_the_worktree_venv_to_path(primary: Path) -> None: + """flox's [profile] hook that exports $PWD/.venv/bin only runs for an + interactive shell, so direnv's non-interactive env dump never carries it. + Without this the venv's console scripts (deckd-dev, deckctl) are missing + from an activated worktree and `just dev-daemon` fails.""" + (primary / ".flox").mkdir() + wt = _add_worktree(primary, "feature-a") + assert _run(wt, "adopt", "--no-install").returncode == 0 + + envrc = (wt / ".envrc").read_text() + assert "PATH_add .venv/bin" in envrc + assert 'export VIRTUAL_ENV="$PWD/.venv"' in envrc + + +def test_adopt_leaves_an_existing_envrc_alone(primary: Path) -> None: + (primary / ".flox").mkdir() + wt = _add_worktree(primary, "feature-a") + (wt / ".envrc").write_text("# hand-rolled\n") + _run(wt, "adopt", "--no-install") + assert (wt / ".envrc").read_text() == "# hand-rolled\n" + + +def test_no_envrc_when_the_primary_does_not_use_direnv(primary: Path) -> None: + """The repo doesn't prescribe direnv or flox — don't impose them.""" + wt = _add_worktree(primary, "feature-a") + _run(wt, "adopt", "--no-install") + assert not (wt / ".envrc").exists()