diff --git a/bin/local-dev/main.sh b/bin/local-dev/main.sh index acc49362a47..8a52e544f79 100755 --- a/bin/local-dev/main.sh +++ b/bin/local-dev/main.sh @@ -35,6 +35,7 @@ # iff every service is running — the # contract for agents/scripts. # bin/local-dev.sh up [--fresh|--build|--no-build] [--skip=svc1,svc2] [--json] +# [--worktree=PATH | --branch=NAME] # Default: skip build if no source/lock # changes since last build. --build forces # incremental sbt dist + yarn/bun install. @@ -42,6 +43,17 @@ # skips the build step entirely. --json # sends progress to stderr and the final # status JSON to stdout. +# DEPLOY SOURCE: with no selector the stack +# is built/run from THIS checkout. Point it +# at a sibling git worktree with +# --worktree=PATH or --branch=NAME (the +# worktree that has NAME checked out) to +# deploy a PR branch without disturbing the +# main checkout. The choice is persisted, so +# later status / down / logs / / auto +# all act on it (run a plain `up` to return +# to this checkout). local-dev.sh itself +# always runs from this checkout. # bin/local-dev.sh down [--skip=svc1,svc2] [--json] # stop every non-skipped service # (--json: summary JSON on stdout). @@ -80,18 +92,145 @@ set -euo pipefail # in-place at every glob site). `failglob` / `nullglob` are opt-in per glob # via `( shopt -s nullglob; ... )` subshells where we need empty-on-no-match. -REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -cd "$REPO_ROOT" +# --------- self tree vs deploy source --------- +# The orchestration tooling — this script, tui.py, and the docker overlay — +# always runs from the checkout it physically lives in: the "self" tree +# (normally the canonical `texera` clone). The *application* we build and run, +# though, can be redirected to a sibling git worktree so you can deploy a PR +# branch without disturbing the main checkout. That target is the "source" +# tree. +# +# bin/local-dev.sh up --worktree=PATH deploy from an explicit worktree dir +# bin/local-dev.sh up --branch=NAME deploy from the worktree that has +# NAME checked out +# bin/local-dev.sh up deploy from this (self) checkout again +# +# The selection is persisted to $STATE_DIR/deploy-source, so every later +# command (status, logs, down, single-service rebuild, auto) reads it back and +# acts on the SAME deployment. REPO_ROOT below is pointed at the source tree, which +# is what the rest of the script keys every build/run/git operation off of — +# only the handful of tooling-file paths are pinned to SELF_ROOT. +SELF_DIR="$(cd "$(dirname "$0")" && pwd)" # bin/local-dev in the self tree +SELF_ROOT="$(cd "$SELF_DIR/../.." && pwd)" # self checkout root (tooling source) STATE_DIR="${TEXERA_LOCAL_DEV_DIR:-/tmp/texera-local-dev}" LOG_DIR="$STATE_DIR/logs" -BUILD_STAMP_DIR="$STATE_DIR/build-stamps" # Per-service phase markers: shell writes `\t` here as it # walks each service through stop → build → start; the TUI reads them # every tick and renders an animated transitional state in the STATE # column. Removed once the service is up / on stale-after-90s. PHASE_DIR="$STATE_DIR/svc-phase" -mkdir -p "$LOG_DIR" "$BUILD_STAMP_DIR" "$PHASE_DIR" +DEPLOY_SOURCE_FILE="$STATE_DIR/deploy-source" +mkdir -p "$LOG_DIR" "$PHASE_DIR" + +# Absolute path of a checkout's git object store (the common dir). For a +# worktree this resolves to the shared `.git` of the main clone, so two trees +# of the same repo report the same value — that's how we tell a real sibling +# worktree apart from an unrelated repo. +_git_common_abs() { + local dir="$1" c="" + c="$(git -C "$dir" rev-parse --git-common-dir 2>/dev/null)" || return 1 + [[ -n "$c" ]] || return 1 + case "$c" in /*) ;; *) c="$dir/$c" ;; esac + ( cd "$c" 2>/dev/null && pwd ) || return 1 +} + +# Validate a candidate deploy-source dir: a directory holding a build.sbt that +# shares this repo's git object store. Echoes the canonical abs path on success. +_validate_source_root() { + local cand="$1" abs="" sc="" cc="" + [[ -n "$cand" && -d "$cand" ]] || return 1 + abs="$(cd "$cand" 2>/dev/null && pwd)" || return 1 + [[ -f "$abs/build.sbt" ]] || return 1 + sc="$(_git_common_abs "$SELF_ROOT")" || return 1 + cc="$(_git_common_abs "$abs")" || return 1 + [[ "$sc" == "$cc" ]] || return 1 + printf '%s\n' "$abs" +} + +# Resolve a branch name to the worktree path that has it checked out (the self +# tree counts — it's a worktree of the shared clone too). +_worktree_for_branch() { + local want="$1" line="" path="" + while IFS= read -r line; do + case "$line" in + "worktree "*) path="${line#worktree }" ;; + "branch refs/heads/$want") printf '%s\n' "$path"; return 0 ;; + esac + done < <(git -C "$SELF_ROOT" worktree list --porcelain 2>/dev/null) + return 1 +} + +# Deploy source resolution: +# • Read-only commands (status / down / logs / ) follow whatever the last +# up/auto deployed — read it back from the persisted pointer. A stale +# pointer (worktree removed/moved) is dropped silently. +# • up / auto re-decide the deployment: --worktree=PATH / --branch=NAME selects +# a sibling worktree. With no selector, `up` means THIS (self) checkout, +# while `auto` keeps following the active deployment so the edit→bounce loop +# stays on it. Either way we (re)persist so read-only commands follow it. +SOURCE_ROOT="$SELF_ROOT" +if [[ -f "$DEPLOY_SOURCE_FILE" ]]; then + _persisted="$(cat "$DEPLOY_SOURCE_FILE" 2>/dev/null || true)" + if _valid="$(_validate_source_root "$_persisted")"; then + SOURCE_ROOT="$_valid" + else + rm -f "$DEPLOY_SOURCE_FILE" + fi +fi + +# up/auto must resolve their target BEFORE build.sbt is parsed (version + sbt +# dep graph key off the source tree), so peek the args here — cmd_up / cmd_auto +# re-see and no-op the selectors in their own parse loops. +if [[ "${1:-}" == "up" || "${1:-}" == "auto" ]]; then + # `up` with no selector resets to this checkout; `auto` keeps the pointer + # value already resolved above. + [[ "${1:-}" == "up" ]] && SOURCE_ROOT="$SELF_ROOT" + for _arg in "${@:2}"; do + case "$_arg" in + --worktree=*) + _t="${_arg#--worktree=}" + if _v="$(_validate_source_root "$_t")"; then + SOURCE_ROOT="$_v" + else + printf "FATAL: --worktree=%s is not a valid texera worktree\n" "$_t" >&2 + printf " (need a directory with build.sbt that shares this repo's .git).\n" >&2 + exit 1 + fi ;; + --branch=*) + _b="${_arg#--branch=}" + if _wt="$(_worktree_for_branch "$_b")" && _v="$(_validate_source_root "$_wt")"; then + SOURCE_ROOT="$_v" + else + printf "FATAL: no git worktree has branch '%s' checked out.\n" "$_b" >&2 + printf " Create one first, e.g.:\n" >&2 + printf " git worktree add ../texera-worktrees/%s %s\n" "${_b//\//-}" "$_b" >&2 + exit 1 + fi ;; + esac + done + # (Re)persist so read-only commands follow this deployment. Self is the + # "no worktree" state, represented by the absence of the pointer file. + if [[ "$SOURCE_ROOT" == "$SELF_ROOT" ]]; then + rm -f "$DEPLOY_SOURCE_FILE" + else + printf '%s\n' "$SOURCE_ROOT" > "$DEPLOY_SOURCE_FILE" + fi +fi + +REPO_ROOT="$SOURCE_ROOT" +export TEXERA_DEPLOY_SOURCE="$SOURCE_ROOT" # tui.py reads this for its banner +cd "$REPO_ROOT" + +# Build stamps are content-hashes of the source tree, so they MUST be scoped +# per deploy source — otherwise a stamp from tree A could suppress the +# (required) first build of tree B, whose target/ is still empty, and the JVM +# launchers would be missing at start time. Namespace them by a stable id +# derived from the absolute source path. +_SRC_ID="$(printf '%s' "$SOURCE_ROOT" | { shasum 2>/dev/null || sha1sum 2>/dev/null || cksum; } | tr -dc 'a-f0-9' | cut -c1-12)" +[[ -z "$_SRC_ID" ]] && _SRC_ID="default" +BUILD_STAMP_DIR="$STATE_DIR/build-stamps/$_SRC_ID" +mkdir -p "$BUILD_STAMP_DIR" # --------- associative-array shim for bash 3.2 --------- # Apple ships bash 3.2 at /bin/bash and we ship licensing as bash 3.2 too, @@ -624,9 +763,13 @@ amap_set SVC_HEALTH frontend "" # --------- docker infra config --------- DOCKER_PROJECT="texera-local-dev" -DOCKER_COMPOSE_FILE="$REPO_ROOT/bin/single-node/docker-compose.yml" -DOCKER_OVERLAY_FILE="$REPO_ROOT/bin/local-dev/docker-compose.override.yml" -DOCKER_ENV_FILE="$REPO_ROOT/bin/single-node/.env" +# Infra orchestration is part of the tooling, not the deployed app — pin it to +# the self tree so a deployed worktree always comes up against main's known-good +# docker compose (the app schema/DDL it applies still comes from the source tree +# via $REPO_ROOT). +DOCKER_COMPOSE_FILE="$SELF_ROOT/bin/single-node/docker-compose.yml" +DOCKER_OVERLAY_FILE="$SELF_ROOT/bin/local-dev/docker-compose.override.yml" +DOCKER_ENV_FILE="$SELF_ROOT/bin/single-node/.env" DOCKER_INFRA_SERVICES=(postgres minio minio-init lakefs lakekeeper-migrate lakekeeper lakekeeper-init litellm) DOCKER_INFRA_LONGLIVED=(postgres minio lakefs lakekeeper litellm) # exclude one-shot init jobs @@ -1027,6 +1170,17 @@ listen_pid_for_port() { lsof -nP -iTCP:"$1" -sTCP:LISTEN -t 2>/dev/null | head -1 || true } +# Branch + short-sha of the deploy source ($REPO_ROOT), tab-separated, each +# falling back to "?" when git can't answer. Single source of truth for the +# banners and the status JSON. Read with: +# IFS=$'\t' read -r branch sha < <(_git_head) +_git_head() { + local branch="" sha="" + branch=$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "?") + sha=$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || echo "?") + printf '%s\t%s\n' "$branch" "$sha" +} + # Returns the count of long-lived infra services currently running under our project. infra_running_count() { docker compose -p "$DOCKER_PROJECT" ps --services --filter status=running 2>/dev/null | grep -cxE "$(IFS=\|; echo "${DOCKER_INFRA_LONGLIVED[*]}")" || true @@ -1808,9 +1962,9 @@ refresh_node_deps() { # would otherwise scrape the dashboard. Exit code mirrors health: 0 iff every # service is running, else 1. emit_status_json() { - local branch="" sha="" - branch=$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "?") - sha=$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || echo "?") + local branch="" sha="" worktree="" + IFS=$'\t' read -r branch sha < <(_git_head) + worktree="$(basename "$REPO_ROOT")" local n_running=0 n_total=0 first=true svc="" type="" port="" state="" pid="" rows="" for svc in "${SERVICES[@]}"; do @@ -1835,8 +1989,8 @@ emit_status_json() { rows+=$(printf '{"service":"%s","port":%s,"type":"%s","pid":%s,"state":"%s"}' \ "$svc" "$port" "$type" "$pid" "$state") done - printf '{"branch":"%s","sha":"%s","running":%d,"total":%d,"services":[%s]}\n' \ - "$branch" "$sha" "$n_running" "$n_total" "$rows" + printf '{"branch":"%s","sha":"%s","worktree":"%s","source":"%s","running":%d,"total":%d,"services":[%s]}\n' \ + "$branch" "$sha" "$worktree" "$REPO_ROOT" "$n_running" "$n_total" "$rows" (( n_running == n_total )) } @@ -1846,10 +2000,10 @@ cmd_status() { "") ;; *) tui_err "unknown flag: $1" >&2; exit 2 ;; esac - local branch="" sha="" - branch=$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "?") - sha=$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || echo "?") - tui_banner "Texera Local Dev" "branch: $branch @ $sha" + local branch="" sha="" wt="" + IFS=$'\t' read -r branch sha < <(_git_head) + [[ "$REPO_ROOT" != "$SELF_ROOT" ]] && wt=" · worktree: $(basename "$REPO_ROOT")" + tui_banner "Texera Local Dev" "branch: $branch @ $sha$wt" # One docker stats call up front — paying the ~2s docker-API cost once # is cheaper than running it per docker service. Indexed by container @@ -1950,6 +2104,9 @@ cmd_up() { --build) BUILD=force ;; --no-build) BUILD=no ;; --json) JSON_OUT=true ;; + # Deploy-target selectors are resolved at startup (they must precede + # the build.sbt parse); accept and ignore them here. + --worktree=*|--branch=*) ;; *) tui_err "unknown flag: $1" >&2; exit 2 ;; esac shift @@ -1967,6 +2124,20 @@ cmd_up() { (( n_skip > 0 )) && skip_label="$n_skip service(s)" tui_banner "Texera Local Dev — bringing stack up" "JDK 17 · skip=$skip_label · build=$BUILD" + # ── Deploy target ───────────────────────────────────────────────────── + # Mark exactly what we are about to build and run, so it is unambiguous in + # the log which branch/worktree/commit this deployment reflects. + local _db="" _ds="" + IFS=$'\t' read -r _db _ds < <(_git_head) + tui_section "Deploy target" + if [[ "$REPO_ROOT" == "$SELF_ROOT" ]]; then + tui_info "checkout: $(basename "$REPO_ROOT") ${DIM}(self / canonical)${RESET}" + else + tui_info "worktree: $(basename "$REPO_ROOT") ${DIM}$REPO_ROOT${RESET}" + tui_info "tooling : $(basename "$SELF_ROOT") ${DIM}(local-dev.sh runs from here)${RESET}" + fi + tui_info "branch : $_db @ $_ds" + # ── Pre-flight short-circuit ─────────────────────────────────────────── # If nothing's changed AND every service is already running, just say so # and exit. Saves the user from scrolling through 30+ "already running" @@ -2104,6 +2275,8 @@ cmd_auto() { while [[ $# -gt 0 ]]; do case "$1" in --skip=*) SKIP_LIST="${1#--skip=}" ;; + # Deploy-target selectors are resolved at startup; accept here. + --worktree=*|--branch=*) ;; *) tui_err "unknown flag: $1" >&2; exit 2 ;; esac shift @@ -2111,6 +2284,9 @@ cmd_auto() { tui_banner "Texera Local Dev — auto bounce" \ "rebuild + bounce only what changed since last build" + if [[ "$REPO_ROOT" != "$SELF_ROOT" ]]; then + tui_info "deploy source: worktree $(basename "$REPO_ROOT") ${DIM}$REPO_ROOT${RESET}" + fi # ── Scan ────────────────────────────────────────────────────────────── tui_section "Scan" @@ -2460,10 +2636,10 @@ cmd_logs() { # Render the interactive dashboard panel (banner + service table + hint + summary). tui_render_dashboard() { printf "\e[2J\e[H" # clear screen + home cursor (scrollback preserved) - local branch="" sha="" - branch=$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "?") - sha=$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || echo "?") - tui_banner "Texera Local Dev — interactive" "branch: $branch @ $sha · $(date '+%H:%M:%S') · type ? for help" + local branch="" sha="" wt="" + IFS=$'\t' read -r branch sha < <(_git_head) + [[ "$REPO_ROOT" != "$SELF_ROOT" ]] && wt="worktree: $(basename "$REPO_ROOT") · " + tui_banner "Texera Local Dev — interactive" "${wt}branch: $branch @ $sha · $(date '+%H:%M:%S') · type ? for help" printf "\n" printf " ${BOLD}%-32s %-7s %-9s %-18s %-3s %s${RESET}\n" \ @@ -2628,7 +2804,7 @@ cmd_interactive() { _install_hint python exit 1 fi - exec "$py" "$REPO_ROOT/bin/local-dev/tui.py" + exec "$py" "$SELF_ROOT/bin/local-dev/tui.py" } # --------- main --------- @@ -2649,6 +2825,6 @@ case "${1:-}" in logs) shift; cmd_logs "${1:-}" ;; w|watch) shift; cmd_watch "${1:-2}" ;; version) printf "%s\n" "$TEXERA_VERSION" ;; - -h|--help) sed -n '18,75p' "$0" ;; + -h|--help) sed -n '18,87p' "$0" ;; *) cmd_update_one "$1" ;; esac diff --git a/bin/local-dev/tests/test_local_dev_sh.sh b/bin/local-dev/tests/test_local_dev_sh.sh index 390b83fdbdd..cd5b067b624 100755 --- a/bin/local-dev/tests/test_local_dev_sh.sh +++ b/bin/local-dev/tests/test_local_dev_sh.sh @@ -314,5 +314,79 @@ for fn in cmd_up cmd_down; do fi done +# 18) Deploy-source: `--help` documents the worktree selectors. +help_out=$("$SCRIPT" --help 2>&1) +if [[ "$help_out" == *"--worktree="* && "$help_out" == *"--branch="* ]]; then + _pass "--help documents --worktree / --branch deploy selectors" +else + _fail "--help doesn't document deploy selectors" +fi + +# Deploy-source tests use an ISOLATED STATE_DIR so they never read or clobber a +# real deployment's persisted pointer. +_ld_state=$(mktemp -d 2>/dev/null || mktemp -d -t ld) +if command -v python3 >/dev/null 2>&1; then + _jq() { printf '%s' "$1" | python3 -c "import sys,json;print(json.load(sys.stdin)[\"$2\"])" 2>/dev/null; } + + # 19) status --json carries the deploy-source fields, defaulting to this + # checkout (no pointer ⇒ worktree == repo dir name, source == REPO_ROOT). + out=$(TEXERA_LOCAL_DEV_DIR="$_ld_state" "$SCRIPT" status --json 2>/dev/null) + wt=$(_jq "$out" worktree); src=$(_jq "$out" source) + if [[ "$wt" == "$(basename "$REPO_ROOT")" && "$src" == "$REPO_ROOT" ]]; then + _pass "status --json reports deploy source (self): worktree=$wt" + else + _fail "status --json deploy-source fields wrong" "worktree=$wt source=$src" + fi + + # 20) A stale pointer (worktree gone) is dropped and we fall back to self. + printf '%s\n' "/no/such/worktree/$$" > "$_ld_state/deploy-source" + out=$(TEXERA_LOCAL_DEV_DIR="$_ld_state" "$SCRIPT" status --json 2>/dev/null) + wt=$(_jq "$out" worktree) + if [[ "$wt" == "$(basename "$REPO_ROOT")" && ! -f "$_ld_state/deploy-source" ]]; then + _pass "stale deploy-source pointer is dropped, falls back to self" + else + _fail "stale pointer not handled" \ + "worktree=$wt pointer=$([[ -f "$_ld_state/deploy-source" ]] && echo present || echo gone)" + fi + + # 21) A valid persisted pointer to a sibling worktree is honored: status + # reports THAT worktree's branch. Create a throwaway worktree, point at + # it, assert, then clean up. + _wt_dir=$(mktemp -d 2>/dev/null || mktemp -d -t ldwt); rm -rf "$_wt_dir" + _wt_branch="ld-test-$$-wt" + if git -C "$REPO_ROOT" worktree add -q -b "$_wt_branch" "$_wt_dir" HEAD 2>/dev/null; then + printf '%s\n' "$_wt_dir" > "$_ld_state/deploy-source" + out=$(TEXERA_LOCAL_DEV_DIR="$_ld_state" "$SCRIPT" status --json 2>/dev/null) + wt=$(_jq "$out" worktree); br=$(_jq "$out" branch) + if [[ "$wt" == "$(basename "$_wt_dir")" && "$br" == "$_wt_branch" ]]; then + _pass "persisted pointer deploys the sibling worktree (branch=$br)" + else + _fail "worktree pointer not honored" "worktree=$wt branch=$br" + fi + git -C "$REPO_ROOT" worktree remove --force "$_wt_dir" 2>/dev/null || true + git -C "$REPO_ROOT" branch -D "$_wt_branch" 2>/dev/null || true + else + _pass "skip: could not create a temp worktree for the pointer test" + fi +else + _pass "skip: python3 not on PATH (deploy-source JSON checks)" +fi + +# 22) Invalid --branch / --worktree fail fast (rc 1) with a clear message, +# BEFORE any build/start (the resolution runs at startup). +out=$(TEXERA_LOCAL_DEV_DIR="$_ld_state" "$SCRIPT" up --branch=__no_such_branch__ 2>&1); rc=$? +if (( rc == 1 )) && [[ "$out" == *"no git worktree has branch"* ]]; then + _pass "up --branch with no worktree fails fast (rc=1)" +else + _fail "invalid --branch not rejected" "rc=$rc out=$(echo "$out" | head -1)" +fi +out=$(TEXERA_LOCAL_DEV_DIR="$_ld_state" "$SCRIPT" up --worktree=/no/such/dir 2>&1); rc=$? +if (( rc == 1 )) && [[ "$out" == *"not a valid texera worktree"* ]]; then + _pass "up --worktree with bad path fails fast (rc=1)" +else + _fail "invalid --worktree not rejected" "rc=$rc out=$(echo "$out" | head -1)" +fi +rm -rf "$_ld_state" + printf "\n%d passed, %d failed\n" "$PASS" "$FAIL" (( FAIL == 0 )) diff --git a/bin/local-dev/tests/test_local_dev_tui.py b/bin/local-dev/tests/test_local_dev_tui.py index 9987a1f1469..0679ef76848 100644 --- a/bin/local-dev/tests/test_local_dev_tui.py +++ b/bin/local-dev/tests/test_local_dev_tui.py @@ -374,3 +374,57 @@ def test_sbt_test_scope_deps_ignored(tui): # DAO appears once (from the main-scope dependsOn) — not twice from # the test-scope one. assert auth_deps.count("DAO") == 1 + + +# ─────────────────── deploy source (worktree) resolution ─────────────────── + +def test_stamp_dir_for_matches_shell_sha1(tui): + """Build stamps are namespaced per deploy source. The id MUST equal the + shell's `sha1(absolute path)[:12]` or the TUI's SRC-dirty column would read + a different worktree's stamps than the shell wrote.""" + import hashlib + src = "/Users/someone/Repos/texera-worktrees/feat-x" + expected = hashlib.sha1(src.encode()).hexdigest()[:12] + assert tui._stamp_dir_for(Path(src)).name == expected + + +def test_resolve_deploy_source_missing_pointer_is_self(tmp_path, monkeypatch, tui): + """No persisted pointer ⇒ deploy from the self tree.""" + monkeypatch.setattr(tui, "SELF_ROOT", tmp_path) + monkeypatch.setattr(tui, "DEPLOY_SOURCE_FILE", tmp_path / "deploy-source") + assert tui._resolve_deploy_source() == tmp_path + + +def test_resolve_deploy_source_stale_pointer_is_self(tmp_path, monkeypatch, tui): + """A pointer to a vanished worktree falls back to self (mirrors the shell, + which also drops the stale pointer).""" + self_root = tmp_path / "self" + self_root.mkdir() + ptr = tmp_path / "deploy-source" + ptr.write_text("/no/such/worktree\n") + monkeypatch.setattr(tui, "SELF_ROOT", self_root) + monkeypatch.setattr(tui, "DEPLOY_SOURCE_FILE", ptr) + assert tui._resolve_deploy_source() == self_root + + +def test_resolve_deploy_source_valid_pointer(tmp_path, monkeypatch, tui): + """A pointer to a real worktree (a dir with a build.sbt) is honored.""" + wt = tmp_path / "wt" + wt.mkdir() + (wt / "build.sbt").write_text("ThisBuild / version := \"x\"\n") + ptr = tmp_path / "deploy-source" + ptr.write_text(str(wt) + "\n") + monkeypatch.setattr(tui, "SELF_ROOT", tmp_path) + monkeypatch.setattr(tui, "DEPLOY_SOURCE_FILE", ptr) + assert tui._resolve_deploy_source() == wt + + +def test_resolve_deploy_source_dir_without_build_sbt_is_self(tmp_path, monkeypatch, tui): + """A pointer to a dir lacking build.sbt is not a checkout — fall back.""" + bogus = tmp_path / "notacheckout" + bogus.mkdir() + ptr = tmp_path / "deploy-source" + ptr.write_text(str(bogus) + "\n") + monkeypatch.setattr(tui, "SELF_ROOT", tmp_path) + monkeypatch.setattr(tui, "DEPLOY_SOURCE_FILE", ptr) + assert tui._resolve_deploy_source() == tmp_path diff --git a/bin/local-dev/tui.py b/bin/local-dev/tui.py index b4469df6768..05ff4342174 100644 --- a/bin/local-dev/tui.py +++ b/bin/local-dev/tui.py @@ -51,10 +51,28 @@ # ─────────────────── Constants ─────────────────── -REPO_ROOT = Path(__file__).resolve().parents[2] +# SELF_ROOT is the tree this TUI (and local-dev.sh) physically lives in. The +# deployed *application* source can be redirected to a sibling worktree via +# `local-dev.sh up --worktree/--branch`; the shell exports its path in +# TEXERA_DEPLOY_SOURCE. REPO_ROOT — used for every build.sbt / src / jar / git +# read below — follows the deploy source so the banner and SRC-dirty column +# reflect what is actually deployed. LOCAL_DEV_SH stays on SELF_ROOT. +SELF_ROOT = Path(__file__).resolve().parents[2] +_DEPLOY_SOURCE = os.environ.get("TEXERA_DEPLOY_SOURCE") +REPO_ROOT = Path(_DEPLOY_SOURCE) if _DEPLOY_SOURCE else SELF_ROOT STATE_DIR = Path(os.environ.get("TEXERA_LOCAL_DEV_DIR", "/tmp/texera-local-dev")) LOG_DIR = STATE_DIR / "logs" -BUILD_STAMP_DIR = STATE_DIR / "build-stamps" +# Where the shell persists the active deploy target; the TUI reads it back when +# the user switches worktree/branch in-session (see _resync_deploy_source). +DEPLOY_SOURCE_FILE = STATE_DIR / "deploy-source" + +# Build stamps are namespaced per deploy source to match the shell (which keys +# them by a sha1 of the absolute source path) — otherwise the SRC-dirty column +# would read another worktree's stamps. +def _stamp_dir_for(src: Path) -> Path: + return STATE_DIR / "build-stamps" / hashlib.sha1(str(src).encode()).hexdigest()[:12] + +BUILD_STAMP_DIR = _stamp_dir_for(REPO_ROOT) # Per-service phase markers written by the shell during stop/build/start. # Each file holds `\t` — we read it back in _tick_state and # render an animated transitional STATE if it's recent (<90s). @@ -64,7 +82,7 @@ LOG_DIR.mkdir(parents=True, exist_ok=True) BUILD_STAMP_DIR.mkdir(parents=True, exist_ok=True) -LOCAL_DEV_SH = REPO_ROOT / "bin" / "local-dev.sh" +LOCAL_DEV_SH = SELF_ROOT / "bin" / "local-dev.sh" DOCKER_PROJECT = "texera-local-dev" HISTORY_FILE = STATE_DIR / "tui-history" @@ -685,6 +703,22 @@ def worktree_info() -> tuple[str, bool]: return name, is_worktree +def _resolve_deploy_source() -> Path: + """Current deploy source: the persisted worktree (if still valid) else the + self tree. Mirrors local-dev.sh's startup resolution so an in-session + `up --branch/--worktree` switch is reflected without relaunching the TUI.""" + try: + if DEPLOY_SOURCE_FILE.exists(): + p = DEPLOY_SOURCE_FILE.read_text().strip() + if p: + cand = Path(p) + if cand.is_dir() and (cand / "build.sbt").is_file(): + return cand + except Exception: + pass + return SELF_ROOT + + def subprocess_run(*argv: str) -> str: import subprocess as sp try: @@ -1310,6 +1344,10 @@ def _show_help(self) -> None: " r refresh state now", " u build + start every service", " u start one service (no rebuild)", + " u --branch=NAME deploy that branch's worktree, then build+start", + " u --worktree=PATH deploy that worktree, then build+start", + " (plain `u` deploys this checkout; works on `a`/`auto` too —", + " the banner re-points to the active tree)", " d stop every service", " d stop one service", " b force incremental sbt + node deps", @@ -1345,16 +1383,30 @@ def _dispatch(self, cmd: str) -> None: verb = parts[0] arg = parts[1] if len(parts) > 1 else "" + # Split the remainder into flags (--foo) and positionals (a service + # name). `up`/`auto` accept the same deploy-target selectors as the CLI + # — --worktree=PATH / --branch=NAME — plus build flags; these are + # forwarded verbatim to bin/local-dev.sh, which resolves and persists + # the target. + tokens = cmd.split()[1:] + flags = [t for t in tokens if t.startswith("-")] + positionals = [t for t in tokens if not t.startswith("-")] + # Resolve to a bin/local-dev.sh invocation. Keeping the shell script # as the canonical engine so behavior matches `bin/local-dev.sh up` # from a terminal. argv: Optional[list[str]] = None if verb in ("u", "up"): - if arg: - if arg not in SERVICES_BY_NAME: - self._log_err(f"unknown service: {arg}") + if flags: + # Deploy-target and/or build flags → forward to `up`. (A bare + # service name is the no-flag form below.) + argv = ["up", *flags] + elif positionals: + svc = positionals[0] + if svc not in SERVICES_BY_NAME: + self._log_err(f"unknown service: {svc}") return - argv = ["start", arg] + argv = ["start", svc] else: argv = ["up"] elif verb in ("d", "down"): @@ -1373,10 +1425,10 @@ def _dispatch(self, cmd: str) -> None: elif verb in ("b", "build"): # Force an incremental build; the shell handles the "is this # really needed" decision itself (it pre-bounces JVMs etc.). - argv = ["up", "--build"] + argv = ["up", "--build", *flags] elif verb in ("a", "auto"): # Scan for dirty services and rebuild + bounce only those. - argv = ["auto"] + argv = ["auto", *flags] elif verb in ("l", "logs", "tail"): if not arg or arg not in SERVICES_BY_NAME: self._log_err(f"usage: l (known: {', '.join(s.name for s in SERVICES)})") @@ -1402,7 +1454,11 @@ def _dispatch(self, cmd: str) -> None: if argv is None: return - self._spawn_action(verb if not arg else f"{verb} {arg}", argv) + # A full `up`/`auto` re-decides (and re-persists) the deploy target — + # including a plain `up`, which resets to this checkout — so re-point the + # banner afterward. `u ` maps to `start` and leaves the target be. + resync = argv[0] in ("up", "auto") + self._spawn_action(verb if not arg else f"{verb} {arg}", argv, resync=resync) def _log_err(self, msg: str) -> None: self._set_log_visible(True) @@ -1467,7 +1523,7 @@ async def _tail_service_log(self, svc: str) -> None: self._cmd_proc = None @work(exclusive=True, group="cmd") - async def _spawn_action(self, label: str, argv: list[str]) -> None: + async def _spawn_action(self, label: str, argv: list[str], resync: bool = False) -> None: log = self.query_one("#log", RichLog) # Cancel any pending auto-hide from a previous command. if self._log_auto_hide_handle is not None: @@ -1489,11 +1545,17 @@ async def _spawn_action(self, label: str, argv: list[str]) -> None: self.cmd_started_at = time.monotonic() self.log_log_position = 0 + # A deploy-target switch is persisted by the shell within ms of launch, + # well before the (possibly minutes-long) build finishes. Flip the + # banner to the new worktree early so it doesn't lag behind the build. + if resync: + self.set_timer(1.0, self._resync_deploy_source) + with REPL_LOG.open("wb") as out: proc = await asyncio.create_subprocess_exec( str(LOCAL_DEV_SH), *argv, stdout=out, stderr=asyncio.subprocess.STDOUT, - cwd=str(REPO_ROOT), + cwd=str(SELF_ROOT), ) self._cmd_proc = proc await proc.wait() @@ -1509,6 +1571,9 @@ async def _spawn_action(self, label: str, argv: list[str]) -> None: # Right after a command, source state likely moved — force a state poll. self._last_dirty_check = 0 self.call_later(self._tick_state) + # Authoritative resync once the command (and its persist) has settled. + if resync: + self.call_later(self._resync_deploy_source) # Successful command → auto-hide the log so the dashboard reclaims the # space. Failure stays visible so the user can read the error. @@ -1522,6 +1587,27 @@ def _auto_hide_log(self) -> None: self._set_log_visible(False) self._log_auto_hide_handle = None + def _resync_deploy_source(self) -> None: + """Re-point the TUI at the persisted deploy target after an in-session + `u`/`a` switch (--worktree/--branch, or a plain `u` back to self). + git_head / worktree_info / + the SRC-dirty checks all read the module-global REPO_ROOT (and + BUILD_STAMP_DIR) at call time, so reassigning them here is enough to + repoint the whole dashboard — then we refresh the banner and re-run the + dirty scan against the new tree.""" + global REPO_ROOT, BUILD_STAMP_DIR + new_root = _resolve_deploy_source() + if new_root != REPO_ROOT: + REPO_ROOT = new_root + BUILD_STAMP_DIR = _stamp_dir_for(new_root) + with contextlib.suppress(Exception): + BUILD_STAMP_DIR.mkdir(parents=True, exist_ok=True) + self._branch, self._sha = git_head() + self._worktree_name, self._is_worktree = worktree_info() + self._update_banner() + self._last_dirty_check = 0.0 + self.call_later(self._tick_state) + def action_toggle_banner(self) -> None: """Collapse / expand the ASCII wordmark to reclaim ~7 rows.""" banner = self.query_one("#banner")