Ctrl K
ComunidadGuías…

scripts/bootstrap-worktree.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bash
#
# Bootstrap a git worktree so the JS/TS test suites "just work".
#
# `git worktree add` only materializes tracked files. node_modules is
# gitignored, and a couple of generated files are gitignored too, so a fresh
# .worktrees/<name> checkout starts without them -- tsx/ts-node test runs and
# even `tsc` then fail with "Cannot find module" until deps are installed.
#
# When the worktree's package-lock.json matches the main checkout's, the main
# checkout's node_modules already satisfies it, so we copy-on-write CLONE it
# (APFS clonefile via `cp -c`, or reflink on Linux) instead of running
# `npm ci`. A clone is near-instant, needs no network, and -- crucially for
# src/node/desktop -- skips electron-rebuild and the failing generate.ts
# postinstall. We clone rather than symlink because tsx/ts-node/electron-mocha
# resolve files via realpath: a symlinked node_modules resolves back into the
# main checkout and its loader hook can't intercept the worktree's .ts files. A
# clone's files have their own worktree realpath, so resolution is correct. We
# clone rather than hardlink because clones are copy-on-write -- a write in the
# worktree can't corrupt the parent's node_modules through a shared inode.
#
# Falls back to `npm ci` when the lockfiles differ (the branch changed deps) or
# the main checkout has no node_modules to clone. `npm ci` runs under the
# repo's pinned node; src/node/desktop requires it, while e2e/rstudio falls
# back to the system node. A marker file in the git dir is set while an install
# runs, so a rerun redoes an install that failed or was interrupted.
#
# Platforms: the clone mechanism is gated by OS -- clonefile on macOS (`cp -c`),
# reflink on Linux (`cp --reflink=auto`). On filesystems without copy-on-write
# (ext4, NTFS) the clone transparently degrades to a full recursive copy:
# correct and still offline, just not instant. Cloning is always from THIS
# machine's main checkout, so the compiled native modules match the platform.
# Run under bash (Git Bash or WSL on Windows).
#
# Alongside the desktop deps, this also materializes a "dev shim" build root
# (<worktree>/build-dev-shim) for running the desktop-dev Playwright suite from
# the worktree without a C++ build. The Electron dev launcher (findComponents
# in src/node/desktop/src/main/utils.ts) honors RSTUDIO_CPP_BUILD_OUTPUT and
# needs <root>/session/rsession.exe plus <root>/conf/rdesktop-dev.conf; the
# shim links session/ from the main checkout's build and rewrites the conf's
# www paths to THIS worktree's GWT output. `npm run test:desktop-dev` detects
# the shim automatically when the worktree has no configured build/ (see
# resolveCppBuildOutput in e2e/rstudio/scripts/dev-common.ts); set
# RSTUDIO_CPP_BUILD_OUTPUT yourself only to override it. Do NOT point
# RSTUDIO_CPP_BUILD_OUTPUT at the main checkout's build/src/cpp directly: its
# conf serves the main checkout's src/gwt/www -- your worktree changes would
# silently not be exercised, and after `ant desktop` / `ant devmode` that
# output is a Super Dev Mode stub that redirects to a code server on
# localhost:9876, so without that code server running RStudio dies at startup
# with "code server not available" and automation times out waiting for
# window.rstudio.ready.
#
# Usage:
#   scripts/bootstrap-worktree.sh [worktree-dir] [--skip-desktop] \
#       [--with-build-dir[=<dir>]]
#
#   worktree-dir   defaults to the current directory; may be any path inside
#                  the target worktree (it is normalized to the repo top).
#   --skip-desktop skip src/node/desktop (its deps are large; skip when you
#                  only need the e2e/rstudio suite). Also skips the dev shim.
#   --with-build-dir[=<dir>]
#                  also configure a CMake build directory for the worktree
#                  (default <worktree>/build; <dir> may be absolute or relative
#                  to the worktree). This configures FRESH -- it never copies
#                  the main checkout's build/, which bakes absolute paths (the
#                  cache's source dir, ninja depfiles, generated dev confs, and
#                  debug/__FILE__ strings in objects), so a copied tree would
#                  build and serve the wrong checkout. A fresh configure writes
#                  correct paths; the cross-checkout CCACHE_BASEDIR setup in
#                  cmake/globals.cmake then lets the compile reuse objects
#                  another checkout already built, keeping it cheap.

set -euo pipefail

SKIP_DESKTOP=0
WANT_BUILD_DIR=0
BUILD_DIR_ARG=""
TARGET=""
for arg in "$@"; do
  case "$arg" in
    --skip-desktop) SKIP_DESKTOP=1 ;;
    --with-build-dir) WANT_BUILD_DIR=1 ;;
    --with-build-dir=*) WANT_BUILD_DIR=1; BUILD_DIR_ARG="${arg#*=}" ;;
    *) TARGET="$arg" ;;
  esac
done

# Normalize to the repo top of the requested (or current) worktree so the
# relative package paths below resolve no matter where this is invoked from.
WORKTREE="$(git -C "${TARGET:-$PWD}" rev-parse --show-toplevel)"

# The primary worktree's .git is a real directory; --git-common-dir points at
# it, so its parent is the main checkout. We clone node_modules and copy
# generated-but-gitignored files from there.
COMMON_DIR="$(git -C "$WORKTREE" rev-parse --path-format=absolute --git-common-dir)"
MAIN_CHECKOUT="$(dirname "$COMMON_DIR")"

echo "[bootstrap] worktree:      $WORKTREE"
echo "[bootstrap] main checkout: $MAIN_CHECKOUT"

# Copy-on-write clone a directory tree (clonefile on macOS/APFS, reflink on
# Linux), falling back to a plain recursive copy where COW is unsupported. The
# destination must not already exist; we clear a partial tree before falling
# back so a failed clone can't nest into the retry.
clone_tree() {
  local src="$1" dst="$2"
  rm -rf "$dst"
  if [ "$(uname -s)" = "Darwin" ]; then
    cp -Rc "$src" "$dst" 2>/dev/null && return 0
  else
    cp -R --reflink=auto "$src" "$dst" 2>/dev/null && return 0
  fi
  rm -rf "$dst"
  cp -R "$src" "$dst"
}

# The pinned node version in a checkout's dependencies/tools/rstudio-tools.sh.
# Prints nothing (rather than failing under set -e) when the file can't be
# read, so callers can report it or fall back to the system node.
pinned_node_version() {
  sed -n 's/^export RSTUDIO_NODE_VERSION="\(.*\)"$/\1/p' \
    "$1/dependencies/tools/rstudio-tools.sh" 2>/dev/null | head -1 || true
}

# Locate the directory holding the pinned node: this worktree, then the main
# checkout, then the tools roots -- mirroring pinnedNodeBinDir in
# e2e/rstudio/scripts/dev-common.ts. Prints nothing when not found.
pinned_node_bin() {
  if [ -z "$PINNED_NODE_VERSION" ]; then
    return
  fi

  # the '-arm64' dir suffix is a macOS-only convention in this repo
  local suffix=""
  if [ "$(uname -s)" = "Darwin" ] && [ "$(uname -m)" = "arm64" ]; then
    suffix="-arm64"
  fi

  # Windows installs node under RSTUDIO_TOOLS_ROOT, which defaults to
  # %SYSTEMDRIVE%\rstudio-tools (dependencies/tools/rstudio-tools.cmd)
  local roots=("$WORKTREE" "$MAIN_CHECKOUT")
  if [ -n "${RSTUDIO_TOOLS_ROOT:-}" ]; then
    roots+=("$RSTUDIO_TOOLS_ROOT")
  fi
  roots+=(/opt/rstudio-tools)
  if command -v cygpath >/dev/null 2>&1; then
    roots+=("${SYSTEMDRIVE:-C:}/rstudio-tools")
  fi

  local root dir
  for root in "${roots[@]}"; do
    # a drive letter's colon would split the PATH entry under Git Bash
    if command -v cygpath >/dev/null 2>&1; then
      root="$(cygpath -u "$root")"
    fi

    # the Windows zip has node.exe at its top level rather than under bin/
    dir="$root/dependencies/common/node/$PINNED_NODE_VERSION$suffix"
    if [ -x "$dir/bin/node" ]; then
      echo "$dir/bin"
      return
    elif [ -x "$dir/node.exe" ]; then
      echo "$dir"
      return
    fi
  done
}

# Exit with instructions for installing the pinned node. The installers read
# the version from their own checkout, so name one that installs this
# worktree's pin: on Windows any checkout's installer writes to the shared
# tools root, and elsewhere the main checkout works unless this branch
# changed the pin.
pinned_node_missing() {
  local pkg="$1"
  local tools="$WORKTREE/dependencies/tools/rstudio-tools.sh"

  if [ -z "$PINNED_NODE_VERSION" ]; then
    echo "[bootstrap] ERROR: couldn't read RSTUDIO_NODE_VERSION from $tools" >&2
    exit 1
  fi

  echo "[bootstrap] ERROR: pinned node $PINNED_NODE_VERSION not found for npm ci in $pkg; install it with:" >&2
  if command -v cygpath >/dev/null 2>&1; then
    echo "[bootstrap]   $(cygpath -w "$WORKTREE/dependencies/windows/install-dependencies.cmd")" >&2
  elif [ "$(pinned_node_version "$MAIN_CHECKOUT")" = "$PINNED_NODE_VERSION" ]; then
    echo "[bootstrap]   (cd \"$MAIN_CHECKOUT/dependencies/common\" && ./install-npm-dependencies)" >&2
  else
    echo "[bootstrap]   (cd \"$WORKTREE/dependencies/common\" && ./install-npm-dependencies)" >&2
  fi
  exit 1
}

# True when main's installed node_modules is current relative to main's own
# lockfile. npm records what it installed in node_modules/.package-lock.json;
# if any package version there disagrees with package-lock.json (or a
# non-optional package is missing entirely), the tree is stale -- typically
# npm ci was not rerun in main after a dependency bump -- and cloning it
# would propagate a broken tree that npm ci in the worktree would not.
main_tree_current() {
  local main="$1"
  [ -f "$main/node_modules/.package-lock.json" ] || return 1
  PATH="${PINNED_NODE_BIN:+$PINNED_NODE_BIN:}$PATH" node -e '
    const fs = require("fs");
    const lock = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
    const installed = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
    for (const [name, meta] of Object.entries(lock.packages ?? {})) {
      if (!name) continue; // root entry
      const inst = installed.packages?.[name];
      // optional (often platform-specific) packages may be legitimately
      // absent from the installed tree
      if (!inst) {
        if (!meta.optional) process.exit(1);
        continue;
      }
      if (meta.version && inst.version !== meta.version) process.exit(1);
    }
  ' "$main/package-lock.json" "$main/node_modules/.package-lock.json"
}

# The marker file that exists while this script installs a package's
# node_modules in a checkout, so a tree left with it is a failed or interrupted
# install. It lives in the checkout's git dir, where npm ci (which empties
# node_modules) and a clone of node_modules can't touch it.
pending_marker() {
  git -C "$1" rev-parse --path-format=absolute --git-path "bootstrap-pending-${2//\//-}"
}

# Make sure a package dir has node_modules: clone from main when the lockfiles
# match and main's installed tree is current, otherwise npm ci. Pass
# "require-pinned-node" to fail rather than npm ci under the system node.
ensure_deps() {
  local pkg="$1"
  local require_pinned="${2:-}"
  local wt="$WORKTREE/$pkg"
  local main="$MAIN_CHECKOUT/$pkg"
  local pending main_pending
  pending="$(pending_marker "$WORKTREE" "$pkg")"
  # empty when the main checkout isn't a checkout (a bare repo's worktree)
  main_pending="$(pending_marker "$MAIN_CHECKOUT" "$pkg" 2>/dev/null || true)"

  if [ ! -f "$wt/package-lock.json" ]; then
    echo "[bootstrap] skip $pkg (no package-lock.json)"
    return
  fi

  if [ -f "$pending" ]; then
    echo "[bootstrap] $pkg: the previous install failed or was interrupted; reinstalling"
  elif [ -d "$wt/node_modules" ]; then
    echo "[bootstrap] $pkg already has node_modules; leaving as-is"
    return
  fi

  if [ "$WORKTREE" != "$MAIN_CHECKOUT" ] \
     && [ -d "$main/node_modules" ] \
     && cmp -s "$wt/package-lock.json" "$main/package-lock.json"; then
    if [ -f "$main_pending" ]; then
      echo "[bootstrap] main's $pkg/node_modules is from a failed or interrupted install; falling back to npm ci"
      echo "[bootstrap]   to clone from main again, rerun this script in the main checkout, or delete $main_pending if main's install was repaired by hand"
    elif main_tree_current "$main"; then
      echo "[bootstrap] cloning $pkg/node_modules from main (lockfiles match)"
      touch "$pending"
      if clone_tree "$main/node_modules" "$wt/node_modules"; then
        rm -f "$pending"
        return
      fi
      echo "[bootstrap] clone failed; falling back to npm ci"
    else
      echo "[bootstrap] main's $pkg/node_modules is stale relative to its lockfile (rerun npm ci there); falling back to npm ci"
    fi
  fi

  # Prefer the repo's pinned node for npm ci: a newer system node can break
  # an install (electron-rebuild's yargs fails to load under Node 26), and the
  # main checkout's dependencies/common/node lags a pin bump until its deps
  # are reinstalled.
  local npm_path="$PATH"
  if [ -n "$PINNED_NODE_BIN" ]; then
    echo "[bootstrap] npm ci in $pkg (using pinned node from $PINNED_NODE_BIN)"
    npm_path="$PINNED_NODE_BIN:$PATH"
  elif [ "$require_pinned" = "require-pinned-node" ]; then
    pinned_node_missing "$pkg"
  else
    echo "[bootstrap] npm ci in $pkg (pinned node not found; using system node)"
  fi

  touch "$pending"
  if ! ( cd "$wt" && PATH="$npm_path" npm ci ); then
    echo "[bootstrap] ERROR: npm ci failed in $pkg; rerun this script to retry the install" >&2
    exit 1
  fi
  rm -f "$pending"
}

# Copy a generated, gitignored file from the main checkout when the worktree is
# missing it (and we are not already in the main checkout). Cloning
# node_modules sidesteps the desktop generate.ts postinstall, but this stub
# lives outside node_modules, so copy it explicitly.
copy_generated() {
  local rel="$1"
  if [ "$WORKTREE" = "$MAIN_CHECKOUT" ] || [ -f "$WORKTREE/$rel" ]; then
    return
  fi
  if [ ! -f "$MAIN_CHECKOUT/$rel" ]; then
    echo "[bootstrap] WARNING: $rel missing from main checkout too; build it there first"
    return
  fi
  echo "[bootstrap] copying generated $rel"
  mkdir -p "$(dirname "$WORKTREE/$rel")"
  cp "$MAIN_CHECKOUT/$rel" "$WORKTREE/$rel"
}

# Materialize the desktop-dev shim build root (see header). Best-effort: when
# the main checkout has no built rsession/conf yet, warn and move on -- the
# rest of the bootstrap is still useful. The conf is regenerated on every run
# (the main checkout's conf may have changed); the session link is only
# created when missing.
make_dev_shim() {
  local dir="$WORKTREE/build-dev-shim"
  local main_cpp="$MAIN_CHECKOUT/build/src/cpp"

  if [ "$WORKTREE" = "$MAIN_CHECKOUT" ]; then
    return
  fi
  if [ ! -f "$main_cpp/conf/rdesktop-dev.conf" ] || [ ! -d "$main_cpp/session" ]; then
    echo "[bootstrap] WARNING: no built session/conf under $main_cpp; build the main checkout first -- skipping dev shim"
    return
  fi

  mkdir -p "$dir/conf"

  # session/ must hold the main checkout's built rsession. A symlink is fine
  # everywhere but Windows, where Git Bash's `ln -s` silently degrades to a
  # copy -- use a real NTFS junction there instead. PowerShell rather than
  # `cmd /c mklink`: cmd's /c quote-stripping and MSYS path conversion both
  # mangle the mklink arguments.
  if [ ! -e "$dir/session" ]; then
    case "$(uname -s)" in
      MINGW*|MSYS*|CYGWIN*)
        powershell.exe -NoProfile -Command \
          "New-Item -ItemType Junction -Path '$(cygpath -w "$dir/session")' -Target '$(cygpath -w "$main_cpp/session")' | Out-Null"
        ;;
      *)
        ln -s "$main_cpp/session" "$dir/session"
        ;;
    esac
  fi

  # Rewrite the conf's www paths to THIS worktree's GWT output; everything
  # else (rpostback, staged R modules, dependency paths) stays pointed at the
  # main checkout's build. The conf wants forward slashes even on Windows.
  local wt_fwd="$WORKTREE"
  if command -v cygpath >/dev/null 2>&1; then
    wt_fwd="$(cygpath -m "$WORKTREE")"
  fi
  sed \
    -e "s|^www-local-path=.*|www-local-path=$wt_fwd/src/gwt/www|" \
    -e "s|^www-symbol-maps-path=.*|www-symbol-maps-path=$wt_fwd/src/gwt/extras/rstudio/symbolMaps|" \
    "$main_cpp/conf/rdesktop-dev.conf" > "$dir/conf/rdesktop-dev.conf"

  echo "[bootstrap] dev shim ready: $dir"
  if [ ! -f "$WORKTREE/src/gwt/www/rstudio/rstudio.nocache.js" ]; then
    echo "[bootstrap] NOTE: worktree GWT output missing; build it first: (cd src/gwt && ant draft)"
  fi
  echo "[bootstrap] run desktop-dev e2e from e2e/rstudio with:"
  echo "[bootstrap]   npm run test:desktop-dev -- <spec>"
  echo "[bootstrap] (the wrapper detects this shim when the worktree has no configured build/)"
}

# Configure a CMake build directory for the worktree. Always a fresh configure
# (cmake -S <worktree> -B <dir>), never a copy of the main checkout's build/ --
# a CMake build tree bakes absolute paths into the cache, ninja depfiles, the
# generated dev confs (www-local-path), and the compiled objects themselves, so
# a copied tree would build and serve the main checkout rather than this one. A
# fresh configure writes correct paths; the cross-checkout CCACHE_BASEDIR setup
# in cmake/globals.cmake makes the subsequent compile reuse cached objects, so
# this stays cheap despite not copying.
configure_build() {
  local dir="$1"

  # Resolve a relative path against the worktree root so the build dir lands
  # inside the worktree no matter where this script was invoked from.
  case "$dir" in
    /*) ;;
    *) dir="$WORKTREE/$dir" ;;
  esac

  if ! command -v cmake >/dev/null 2>&1; then
    echo "[bootstrap] WARNING: cmake not found on PATH; skipping --with-build-dir"
    return
  fi

  # Ninja, because CMake's default Makefiles generator compiles serially under
  # a bare `cmake --build` (what rserver-dev runs).
  echo "[bootstrap] configuring CMake build dir: $dir"
  cmake -S "$WORKTREE" -B "$dir" -G Ninja -DCMAKE_EXPORT_COMPILE_COMMANDS=1

  echo "[bootstrap] build dir configured; compile with: cmake --build \"$dir\""
}

PINNED_NODE_VERSION="$(pinned_node_version "$WORKTREE")"
PINNED_NODE_BIN="$(pinned_node_bin)"

# The e2e/rstudio Playwright suite -- the common case. It has no install
# scripts, so the system node can install it.
ensure_deps "e2e/rstudio"

# The Electron desktop suite. Its postinstall's electron-rebuild needs the
# pinned node. Cloning skips that and the failing generate.ts postinstall; we
# still copy the type stub that postinstall would have generated (it lives
# outside node_modules).
if [ "$SKIP_DESKTOP" -eq 0 ]; then
  ensure_deps "src/node/desktop" require-pinned-node
  copy_generated "src/node/desktop/src/types/user-state-schema.d.ts"
  make_dev_shim
else
  echo "[bootstrap] skipping src/node/desktop (--skip-desktop)"
fi

# Optional CMake build-dir configure (default <worktree>/build when no path
# was given).
if [ "$WANT_BUILD_DIR" -eq 1 ]; then
  configure_build "${BUILD_DIR_ARG:-build}"
fi

echo "[bootstrap] done"