You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/07/03 18:33:50 UTC
(texera) branch gh-readonly-queue/main/pr-6031-b30f928dc48e4873d594737e3ccd4d2ffcb7e5ff created (now 67b6612b47)
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a change to branch gh-readonly-queue/main/pr-6031-b30f928dc48e4873d594737e3ccd4d2ffcb7e5ff
in repository https://gitbox.apache.org/repos/asf/texera.git
at 67b6612b47 feat(local-dev): deploy from a sibling git worktree via up --worktree/--branch (#6031)
This branch includes the following new commits:
new 67b6612b47 feat(local-dev): deploy from a sibling git worktree via up --worktree/--branch (#6031)
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(texera) 01/01: feat(local-dev): deploy from a sibling git worktree via up --worktree/--branch (#6031)
Posted by gi...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-6031-b30f928dc48e4873d594737e3ccd4d2ffcb7e5ff
in repository https://gitbox.apache.org/repos/asf/texera.git
commit 67b6612b471f99bdd689c6fa58359a9fac3c6bb8
Author: Yicong Huang <17...@users.noreply.github.com>
AuthorDate: Fri Jul 3 11:33:29 2026 -0700
feat(local-dev): deploy from a sibling git worktree via up --worktree/--branch (#6031)
### What changes were proposed in this PR?
`bin/local-dev.sh` can now build and run the stack from a **sibling git
worktree** while the orchestration tooling itself always runs from the
canonical checkout.
The key split is **SELF_ROOT** (where `local-dev.sh`, `tui.py`, and the
docker overlay live — the tooling) vs **SOURCE_ROOT** (the application
tree that gets built/run, which `REPO_ROOT` now points at).
- `up` / `auto` accept `--worktree=PATH` or `--branch=NAME` (resolved to
the worktree that has the branch checked out). A plain `up` returns to
this checkout.
- The selection persists to `$STATE_DIR/deploy-source`, so `status` /
`down` / `logs` / single-service rebuild / `auto` all act on the active
deployment. `up` re-marks the deploy target (branch / sha / worktree) at
startup, and `status --json` gains `"worktree"` and `"source"` fields.
- Build stamps are namespaced per source tree (sha1 of the absolute
path), so switching worktrees can't let a stamp from tree A suppress the
required first build of tree B.
- Infra docker compose / `.env` / overlay stay pinned to the canonical
checkout (orchestration), while the app schema/DDL still comes from the
deployed source.
- The interactive TUI mirrors the CLI: `u` / `a` accept the same
selectors, and the banner + SRC-dirty column re-point to the active tree
live (the shell exports `TEXERA_DEPLOY_SOURCE`; the TUI re-reads the
persisted pointer after a switch).
- Invalid `--branch` / `--worktree` fail fast with a clear, actionable
message before anything is built or started.
### Any related issues, documentation, discussions?
Closes #6030. The `--help` text and the in-TUI help (`h`) document the
new selectors.
### How was this PR tested?
Extended both existing local-dev test suites (all green):
- `bin/local-dev/tests/test_local_dev_sh.sh` — 25 checks (6 new):
`--help` documents the selectors; `status --json` carries
`worktree`/`source`; a stale pointer is dropped and falls back to self;
a valid pointer to a throwaway worktree (created and cleaned up in-test)
is honored and reports its branch; invalid `--branch` and `--worktree`
fail fast with rc=1.
- `bin/local-dev/tests/test_local_dev_tui.py` — 43 checks (5 new):
`_stamp_dir_for` matches the shell's `sha1(path)[:12]` namespacing;
`_resolve_deploy_source` returns self when the pointer is
missing/stale/points at a non-checkout, and the worktree when valid.
Run:
```
bash bin/local-dev/tests/test_local_dev_sh.sh
python3 -m pytest bin/local-dev/tests/test_local_dev_tui.py -q
```
Also verified manually that `status --json` against a persisted worktree
pointer reports that worktree's branch/sha/source, and that `bash -n` /
`py_compile` pass.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Opus 4.8 (Claude Code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <no...@anthropic.com>
---
bin/local-dev/main.sh | 241 +++++++++++++++++++++++++++---
bin/local-dev/tests/test_local_dev_sh.sh | 99 ++++++++++++
bin/local-dev/tests/test_local_dev_tui.py | 54 +++++++
bin/local-dev/tui.py | 112 ++++++++++++--
4 files changed, 472 insertions(+), 34 deletions(-)
diff --git a/bin/local-dev/main.sh b/bin/local-dev/main.sh
index acc49362a4..5d8ca22169 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,22 @@
# 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 / <svc> / auto
+# all act on it (run a plain `up` to return
+# to this checkout). local-dev.sh itself
+# always runs from this checkout — so if the
+# target branch modifies bin/local-dev/**,
+# those tooling changes are NOT in effect;
+# checkout that branch and run its own
+# local-dev.sh instead (a warning is printed
+# when such drift is detected).
# bin/local-dev.sh down [--skip=svc1,svc2] [--json]
# stop every non-skipped service
# (--json: summary JSON on stdout).
@@ -80,18 +97,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 `<phase>\t<epoch>` 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 / <svc>) 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 +768,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 +1175,31 @@ 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"
+}
+
+# When deploying a sibling worktree, the tooling (this script, tui.py, the
+# docker overlay) still runs from the self tree — deliberately. The one case
+# where that surprises people is a target branch that itself modifies
+# bin/local-dev/**: those changes are NOT in effect. Print a one-line warning
+# (informational, non-fatal) so the boundary is visible before someone burns
+# time debugging it.
+_warn_tooling_drift() {
+ [[ "$REPO_ROOT" == "$SELF_ROOT" ]] && return 0
+ if ! diff -rq "$SELF_ROOT/bin/local-dev" "$REPO_ROOT/bin/local-dev" >/dev/null 2>&1; then
+ tui_warn "target's bin/local-dev/ differs from this checkout's — the tooling runs from HERE, so those changes are NOT in effect"
+ printf " ${DIM}(to exercise the target's tooling changes, run that worktree's own bin/local-dev.sh)${RESET}\n"
+ fi
+}
+
# 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 +1981,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 +2008,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 +2019,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 +2123,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 +2143,21 @@ 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"
+ _warn_tooling_drift
+
# ── 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 +2295,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 +2304,10 @@ 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}"
+ _warn_tooling_drift
+ fi
# ── Scan ──────────────────────────────────────────────────────────────
tui_section "Scan"
@@ -2460,10 +2657,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 +2825,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 +2846,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,92p' "$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 390b83fdbd..4f0a8d61a5 100755
--- a/bin/local-dev/tests/test_local_dev_sh.sh
+++ b/bin/local-dev/tests/test_local_dev_sh.sh
@@ -314,5 +314,104 @@ 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"
+
+# 23) Tooling-drift boundary is surfaced: --help documents that the target's
+# bin/local-dev/** changes are NOT in effect, and _warn_tooling_drift is
+# wired into both cmd_up and cmd_auto (structural — firing it for real
+# would need a full `up`).
+if [[ "$help_out" == *"NOT in effect"* ]]; then
+ _pass "--help documents the tooling-runs-from-self boundary"
+else
+ _fail "--help doesn't document the tooling boundary"
+fi
+drift_body=$(awk '/^_warn_tooling_drift\(\)/{f=1} f{print} f&&/^}/{exit}' "$REPO_ROOT/bin/local-dev/main.sh")
+if [[ "$drift_body" == *"diff -rq"* && "$drift_body" == *"bin/local-dev"* ]]; then
+ _pass "_warn_tooling_drift diffs the target's bin/local-dev/"
+else
+ _fail "_warn_tooling_drift missing or doesn't diff bin/local-dev/"
+fi
+for fn in cmd_up cmd_auto; do
+ body=$(awk -v fn="$fn" '$0 ~ "^" fn "\\(\\)" {f=1} f{print} f&&/^}/{exit}' \
+ "$REPO_ROOT/bin/local-dev/main.sh")
+ if [[ "$body" == *"_warn_tooling_drift"* ]]; then
+ _pass "$fn calls _warn_tooling_drift"
+ else
+ _fail "$fn doesn't call _warn_tooling_drift"
+ fi
+done
+
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 9987a1f146..0679ef7684 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 b4469df676..3170f60077 100644
--- a/bin/local-dev/tui.py
+++ b/bin/local-dev/tui.py
@@ -51,10 +51,28 @@ from textual.widgets import DataTable, Input, RichLog, Static
# ─────────────────── 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 `<phase>\t<epoch>` — we read it back in _tick_state and
# render an animated transitional STATE if it's recent (<90s).
@@ -64,7 +82,7 @@ REPL_LOG = LOG_DIR / "repl.log"
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,12 @@ class LocalDevApp(App):
" r refresh state now",
" u build + start every service",
" u <svc> 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. Note: the tooling",
+ " itself always runs from this checkout, so a target that",
+ " modifies bin/local-dev/** needs its own local-dev.sh)",
" d stop every service",
" d <svc> stop one service",
" b force incremental sbt + node deps",
@@ -1345,16 +1385,30 @@ class LocalDevApp(App):
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 +1427,10 @@ class LocalDevApp(App):
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 <service> (known: {', '.join(s.name for s in SERVICES)})")
@@ -1402,7 +1456,11 @@ class LocalDevApp(App):
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 <svc>` 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 +1525,7 @@ class LocalDevApp(App):
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 +1547,17 @@ class LocalDevApp(App):
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 +1573,9 @@ class LocalDevApp(App):
# 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 +1589,27 @@ class LocalDevApp(App):
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")