From dbd9e76027f2c39302cbd875480b88b4c30bf950 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 28 Jun 2026 06:48:37 +0200 Subject: [PATCH] feat(dev-env): shell scripts (git-hooks, worktree, deploy, regtest, nav) and smoke test Co-Authored-By: Claude Opus 4.8 --- modules/dev-env/scripts/bootstrap.sh | 293 +++++++++++++ modules/dev-env/scripts/deploy.sh | 215 ++++++++++ modules/dev-env/scripts/git-hooks/pre-commit | 138 +++++++ modules/dev-env/scripts/lib-colors.sh | 17 + modules/dev-env/scripts/lib.sh | 24 ++ modules/dev-env/scripts/nav.sh | 203 +++++++++ modules/dev-env/scripts/pr-helpers.sh | 154 +++++++ modules/dev-env/scripts/rebase.sh | 410 +++++++++++++++++++ modules/dev-env/scripts/regtest.sh | 284 +++++++++++++ modules/dev-env/scripts/status.sh | 142 +++++++ modules/dev-env/scripts/tmux-launch.sh | 131 ++++++ modules/dev-env/scripts/worktree.sh | 312 ++++++++++++++ modules/dev-env/tests/smoke.nix | 173 ++++++++ 13 files changed, 2496 insertions(+) create mode 100644 modules/dev-env/scripts/bootstrap.sh create mode 100644 modules/dev-env/scripts/deploy.sh create mode 100644 modules/dev-env/scripts/git-hooks/pre-commit create mode 100644 modules/dev-env/scripts/lib-colors.sh create mode 100644 modules/dev-env/scripts/lib.sh create mode 100644 modules/dev-env/scripts/nav.sh create mode 100644 modules/dev-env/scripts/pr-helpers.sh create mode 100644 modules/dev-env/scripts/rebase.sh create mode 100644 modules/dev-env/scripts/regtest.sh create mode 100644 modules/dev-env/scripts/status.sh create mode 100644 modules/dev-env/scripts/tmux-launch.sh create mode 100644 modules/dev-env/scripts/worktree.sh create mode 100644 modules/dev-env/tests/smoke.nix diff --git a/modules/dev-env/scripts/bootstrap.sh b/modules/dev-env/scripts/bootstrap.sh new file mode 100644 index 0000000..4f8e390 --- /dev/null +++ b/modules/dev-env/scripts/bootstrap.sh @@ -0,0 +1,293 @@ +#!/usr/bin/env bash +# dev-env-bootstrap: idempotent materialization of bare repos and worktrees +# +# Reads /etc/dev-env/projects.json (rendered by config.nix) and ensures +# every declared project has: +# +# 1. A bare repo at ${REPOS_DIR}/.git with the declared +# remotes (origin = forgejo, upstream = github OSS, github-fork = +# personal github). +# 2. A worktree at the declared path for each entry in `worktrees`. +# 3. (Optional) A `.envrc` containing `use flake` if the worktree has +# a flake.nix and DEVENV_WRITE_DIRENV_HINTS=1. +# +# Safety: +# - Never clobbers an existing worktree whose current branch differs +# from the declared one — prints a warning and skips. +# - Never silently rewrites a remote URL — prints the diff and asks. +# - --dry-run shows everything that would happen without touching anything. +# - Re-running is a no-op when the tree already matches the spec. + +set -euo pipefail + +# --- Config ----------------------------------------------------------- + +if [[ -r /etc/dev-env/config.sh ]]; then + # shellcheck disable=SC1091 + source /etc/dev-env/config.sh +elif [[ -r "$HOME/dev/.devenv.conf" ]]; then + # shellcheck disable=SC1091 + source "$HOME/dev/.devenv.conf" +fi + +DEV_ROOT="${DEV_ROOT:-$HOME/dev}" +REPOS_DIR="${REPOS_DIR:-$DEV_ROOT/repos}" +PROJECTS_JSON="${DEVENV_PROJECTS_JSON:-/etc/dev-env/projects.json}" +WRITE_DIRENV="${DEVENV_WRITE_DIRENV_HINTS:-1}" + +# --- Args ------------------------------------------------------------- + +DRY_RUN=false +VERBOSE=false +FORCE_REMOTES=false +ONLY_PROJECT="" + +usage() { + cat <&2; } +trace() { if $VERBOSE; then echo -e "${DIM}\$ $*${NC}"; fi; } + +run() { + trace "$*" + if $DRY_RUN; then + echo " (dry-run) $*" + else + "$@" + fi +} + +# --- Preflight -------------------------------------------------------- + +if [[ ! -r "$PROJECTS_JSON" ]]; then + err "projects.json not found at $PROJECTS_JSON" + err "is dev-env enabled in your nixos config?" + exit 1 +fi + +if ! command -v jq >/dev/null; then + err "jq is required (should be in environment.systemPackages)" + exit 1 +fi + +if ! command -v git >/dev/null; then + err "git is required" + exit 1 +fi + +run mkdir -p "$REPOS_DIR" "$DEV_ROOT" + +# --- Per-project work ------------------------------------------------- + +# Returns "remote-nameurl" for the given project, one line per remote. +project_remotes_jq() { + local proj="$1" + jq -r --arg p "$proj" ' + .[$p].remotes + | to_entries[] + | "\(.key)\t\(.value)" + ' "$PROJECTS_JSON" +} + +# Returns "wtnamebranchpathremote" for each declared worktree. +project_worktrees_jq() { + local proj="$1" + jq -r --arg p "$proj" ' + .[$p].worktrees + | to_entries[] + | "\(.key)\t\(.value.branch)\t\(.value.path)\t\(.value.remote)" + ' "$PROJECTS_JSON" +} + +ensure_bare_repo() { + local proj="$1" + local bare_path + bare_path="$(jq -r --arg p "$proj" '.[$p].barePath' "$PROJECTS_JSON")" + + if [[ ! -d "$bare_path" ]]; then + info "creating bare repo $bare_path" + run git init --bare "$bare_path" + fi + + while IFS=$'\t' read -r remote_name url; do + [[ -z "$remote_name" ]] && continue + local current="" + current="$(git -C "$bare_path" remote get-url "$remote_name" 2>/dev/null || echo "")" + if [[ -z "$current" ]]; then + info " + remote $remote_name → $url" + run git -C "$bare_path" remote add "$remote_name" "$url" + elif [[ "$current" != "$url" ]]; then + warn " ! remote $remote_name URL differs:" + warn " have: $current" + warn " want: $url" + if $FORCE_REMOTES; then + info " (--force-remotes) updating" + run git -C "$bare_path" remote set-url "$remote_name" "$url" + else + warn " (skipped — pass --force-remotes to overwrite)" + fi + fi + done < <(project_remotes_jq "$proj") + + # Initial fetch (one-shot, only if origin has never been fetched) + if [[ ! -d "$bare_path/refs/remotes/origin" ]]; then + info " fetching origin (first time)" + run git -C "$bare_path" fetch origin 2>&1 | sed 's/^/ /' || \ + warn " fetch failed — check ssh access to origin" + fi + if git -C "$bare_path" remote get-url upstream &>/dev/null \ + && [[ ! -d "$bare_path/refs/remotes/upstream" ]]; then + info " fetching upstream (first time)" + run git -C "$bare_path" fetch upstream 2>&1 | sed 's/^/ /' || \ + warn " upstream fetch failed" + fi +} + +ensure_worktrees() { + local proj="$1" + local bare_path is_clone + bare_path="$(jq -r --arg p "$proj" '.[$p].barePath' "$PROJECTS_JSON")" + is_clone="$(jq -r --arg p "$proj" '.[$p].isClone' "$PROJECTS_JSON")" + + # Single-clone projects: clone to category/projectname instead of using worktrees + if [[ "$is_clone" == "true" ]]; then + local clone_path + clone_path="$(jq -r --arg p "$proj" '.[$p].clonePath' "$PROJECTS_JSON")" + if [[ -d "$clone_path/.git" ]]; then + ok " clone exists: $clone_path" + return 0 + fi + local origin_url + origin_url="$(jq -r --arg p "$proj" '.[$p].remotes.origin' "$PROJECTS_JSON")" + info " cloning $origin_url → $clone_path" + run mkdir -p "$(dirname "$clone_path")" + run git clone "$origin_url" "$clone_path" || warn " clone failed" + return 0 + fi + + while IFS=$'\t' read -r wt_name branch wt_path remote; do + [[ -z "$wt_name" ]] && continue + [[ "$wt_path" == "null" ]] && wt_path="" + + if [[ -z "$wt_path" ]]; then + warn " worktree $wt_name has no path; skipping" + continue + fi + + if [[ -d "$wt_path/.git" ]] || [[ -f "$wt_path/.git" ]]; then + local current_branch + current_branch="$(git -C "$wt_path" branch --show-current 2>/dev/null || echo '?')" + if [[ "$current_branch" == "$branch" ]]; then + ok " worktree $wt_name @ $branch (exists)" + else + warn " worktree $wt_name @ $current_branch (declared: $branch) — leaving alone" + fi + maybe_write_envrc "$wt_path" + continue + fi + + if [[ -e "$wt_path" ]]; then + warn " $wt_path exists but is not a git worktree; skipping" + continue + fi + + # Need to create the worktree. First make sure the local branch exists. + if ! git -C "$bare_path" show-ref --verify --quiet "refs/heads/$branch"; then + if git -C "$bare_path" show-ref --verify --quiet "refs/remotes/$remote/$branch"; then + info " creating local branch $branch from $remote/$branch" + run git -C "$bare_path" branch "$branch" "$remote/$branch" + else + warn " branch $branch not found on $remote — fetch and retry" + continue + fi + fi + + info " + worktree $wt_name → $wt_path ($branch)" + run mkdir -p "$(dirname "$wt_path")" + run git -C "$bare_path" worktree add "$wt_path" "$branch" + maybe_write_envrc "$wt_path" + done < <(project_worktrees_jq "$proj") +} + +maybe_write_envrc() { + local wt="$1" + [[ "$WRITE_DIRENV" != "1" ]] && return 0 + [[ -f "$wt/flake.nix" ]] || return 0 + [[ -e "$wt/.envrc" ]] && return 0 + info " + .envrc (use flake) → $wt/.envrc" + if ! $DRY_RUN; then + echo "use flake" > "$wt/.envrc" + fi +} + +# --- Main loop -------------------------------------------------------- + +mapfile -t PROJECTS < <(jq -r 'keys[]' "$PROJECTS_JSON") + +if [[ -n "$ONLY_PROJECT" ]]; then + if ! printf '%s\n' "${PROJECTS[@]}" | grep -qx "$ONLY_PROJECT"; then + err "project '$ONLY_PROJECT' not in $PROJECTS_JSON" + exit 1 + fi + PROJECTS=("$ONLY_PROJECT") +fi + +echo "" +echo "dev-env bootstrap" +echo " root: $DEV_ROOT" +echo " projects: ${#PROJECTS[@]}" +if $DRY_RUN; then echo " mode: dry-run"; fi +echo "" + +for proj in "${PROJECTS[@]}"; do + echo "─── $proj ───" + ensure_bare_repo "$proj" + ensure_worktrees "$proj" + echo "" +done + +ok "bootstrap complete" +if $DRY_RUN; then echo "(no changes were made)"; fi diff --git a/modules/dev-env/scripts/deploy.sh b/modules/dev-env/scripts/deploy.sh new file mode 100644 index 0000000..3c13d5e --- /dev/null +++ b/modules/dev-env/scripts/deploy.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +# dev-deploy: thin wrapper around the unified deploy flake +# +# Usage: +# dev-deploy switch on host (uses locked deploy flake input) +# dev-deploy test test build (no switch) +# dev-deploy build local build only +# dev-deploy --local same as switch but with --override-input +# wired up from local worktrees so the deploy +# builds against your in-progress changes +# dev-deploy all [test|build] every host in parallel +# dev-deploy update nix flake update +# +# Deploy host → SSH target mapping comes from /etc/dev-env/config.sh. + +set -euo pipefail + +if [[ -r /etc/dev-env/config.sh ]]; then + # shellcheck disable=SC1091 + source /etc/dev-env/config.sh +fi + +DEPLOY_DIR="${DEPLOY_DIR:-$DEV_ROOT/deploy}/${DEVENV_DEPLOY_FLAKE_INPUT:-unified}" +PROJECTS_JSON="${DEVENV_PROJECTS_JSON:-/etc/dev-env/projects.json}" + +usage() { + cat < deploy (switch) + dev-deploy test test build (no switch) + dev-deploy build local build only (no target) + dev-deploy --local deploy with --override-input from local worktrees + dev-deploy --local test test with overrides + dev-deploy all [test|build] every host in parallel + dev-deploy update [args...] nix flake update + +Configured targets: +EOF + if [[ -n "${!DEPLOY_TARGETS_KEYS[*]:-}" ]] 2>/dev/null \ + || [[ "${DEPLOY_TARGETS-}" == *"="* ]]; then + # DEPLOY_TARGETS is rendered as either a bash assoc array or + # KEY=VALUE lines depending on the shell — print whatever's set. + env | grep '^DEPLOY_TARGET_' | sed 's/^DEPLOY_TARGET_/ /' | sed 's/=/ → /' || true + fi + if [[ -r "$DEPLOY_DIR/deploy.sh" ]]; then + echo "" + echo "Backing flake: $DEPLOY_DIR" + fi + exit 1 +} + +[[ $# -lt 1 ]] && usage + +# Pull the host → target map. We accept either: +# 1. DEPLOY_TARGET_= (rendered by config.nix as env vars) +# 2. ${DEPLOY_DIR}/deploy.sh's TARGETS associative array (fallback) +target_for() { + local host="$1" + local var="DEPLOY_TARGET_${host//-/_}" + if [[ -n "${!var:-}" ]]; then + echo "${!var}" + return + fi + # Fallback: parse the deploy.sh TARGETS array (simple regex; brittle + # but enough for our case) + if [[ -r "$DEPLOY_DIR/deploy.sh" ]]; then + awk -v h="$host" ' + /^[[:space:]]*\[/ { + if (match($0, /\[([^]]+)\]="([^"]*)"/, m)) { + if (m[1] == h) { print m[2]; exit } + } + } + ' "$DEPLOY_DIR/deploy.sh" + fi +} + +# --- subcommands --- + +cmd_update() { + shift + cd "$DEPLOY_DIR" + nix flake update "$@" +} + +# Build the --override-input flags for a host using projects.json's +# `deployFlakeInput` mapping. For each project that has deployFlakeInput +# set AND a worktree resolved to a real on-disk path, we emit +# --override-input path: +# Picks the first worktree by default; pass DEVENV_OVERRIDE_WORKTREE= +# to choose a specific one. +build_overrides() { + local args=() + local prefer="${DEVENV_OVERRIDE_WORKTREE:-}" + [[ -r "$PROJECTS_JSON" ]] || { printf '%s\n' ""; return; } + + while IFS=$'\t' read -r input wt_path; do + [[ -z "$input" || "$input" == "null" ]] && continue + [[ -d "$wt_path" ]] || continue + args+=(--override-input "$input" "path:$wt_path") + done < <(jq -r --arg prefer "$prefer" ' + to_entries[] + | select(.value.deployFlakeInput != null) + | .value as $v + | (if ($prefer | length) > 0 and ($v.worktrees | has($prefer)) + then $v.worktrees[$prefer].path + else + ($v.worktrees | to_entries | first.value.path // null) + end) as $path + | "\($v.deployFlakeInput)\t\($path // "")" + ' "$PROJECTS_JSON") + + printf '%s\0' "${args[@]}" +} + +run_host() { + local host="$1" action="$2" use_local="$3" + local target + target="$(target_for "$host")" + + if [[ -z "$target" ]] && [[ "$action" != "build" ]]; then + echo "no SSH target for host '$host' (set DEPLOY_TARGET_${host//-/_})" >&2 + return 1 + fi + + cd "$DEPLOY_DIR" + + local overrides=() + if $use_local; then + # Read the null-delimited overrides into an array + while IFS= read -r -d '' arg; do + [[ -n "$arg" ]] && overrides+=("$arg") + done < <(build_overrides) + if (( ${#overrides[@]} > 0 )); then + echo "[$host] using ${#overrides[@]} local override(s):" + local i=0 + while (( i < ${#overrides[@]} )); do + echo " ${overrides[$((i+1))]} -> ${overrides[$((i+2))]}" + i=$((i + 3)) + done + fi + fi + + case "$action" in + deploy) + echo "[$host] switch on $target..." + nixos-rebuild switch \ + --flake ".#$host" \ + --target-host "$target" \ + "${overrides[@]}" + ;; + test) + echo "[$host] test on $target..." + nixos-rebuild test \ + --flake ".#$host" \ + --target-host "$target" \ + "${overrides[@]}" + ;; + build) + echo "[$host] local build..." + nix build \ + ".#nixosConfigurations.$host.config.system.build.toplevel" \ + "${overrides[@]}" + ;; + *) + echo "unknown action: $action" >&2 + return 1 + ;; + esac +} + +USE_LOCAL=false +if [[ "$1" == "--local" ]]; then + USE_LOCAL=true + shift +fi + +case "$1" in + update) + cmd_update "$@" + ;; + all) + action="${2:-deploy}" + # iterate every DEPLOY_TARGET_* env var + pids=() + hosts=() + for var in $(env | grep -o '^DEPLOY_TARGET_[A-Za-z0-9_]*' || true); do + host="${var#DEPLOY_TARGET_}" + host="${host//_/-}" + run_host "$host" "$action" "$USE_LOCAL" & + pids+=($!) + hosts+=("$host") + done + failed=() + for i in "${!pids[@]}"; do + if ! wait "${pids[$i]}"; then + failed+=("${hosts[$i]}") + fi + done + if (( ${#failed[@]} > 0 )); then + echo "FAILED: ${failed[*]}" + exit 1 + fi + echo "All hosts deployed successfully." + ;; + -h|--help) + usage + ;; + *) + host="$1" + action="${2:-deploy}" + run_host "$host" "$action" "$USE_LOCAL" + ;; +esac diff --git a/modules/dev-env/scripts/git-hooks/pre-commit b/modules/dev-env/scripts/git-hooks/pre-commit new file mode 100644 index 0000000..699e16f --- /dev/null +++ b/modules/dev-env/scripts/git-hooks/pre-commit @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# dev-env shared pre-commit hook. +# +# Wired into every dev-env-managed repo via core.hooksPath. Refuses to +# commit obvious secrets and unencrypted sops files. Body is identical +# to the one in ~/omarchy-dev-env/setup/install-git-hooks.sh — that +# script's per-repo install loop is replaced by core.hooksPath. +# +# False positives: `git commit --no-verify` to bypass. + +set -u + +# Patterns are assembled from fragments so the literal text in this +# file doesn't match the patterns themselves — otherwise this hook +# would always block commits that touch it. +_PVT='PRI''VATE' +_AWS='AWS_SEC''RET_ACCESS_KEY' +FORBIDDEN_PATTERNS=( + "${_PVT} KEY" + "BEGIN RSA ${_PVT}" + "BEGIN EC ${_PVT}" + "BEGIN OPENSSH ${_PVT}" + 'password\s*=\s*["\x27][^"\x27]+' + 'secret\s*=\s*["\x27][^"\x27]+' + 'api_key\s*=\s*["\x27][^"\x27]+' + 'admin_key\s*=\s*["\x27][^"\x27]+' + "${_AWS}" + 'POSTGRES_PASSWORD=(?!.*(example|changeme|placeholder))' +) + +SKIP_FILES=( + '*.md' + '*.txt' + 'secrets-management.md' + # The hook scripts themselves contain the FORBIDDEN_PATTERNS as + # literals — scanning them would always self-trigger. + 'modules/dev-env/scripts/git-hooks/*' + # Auth test fixtures: dozens of password="secret1234" placeholders + # across the file — line-level markers would be ridiculous; this is + # exactly the kind of file SKIP_FILES exists for. + 'tests/api/test_auth.py' +) + +# Line-level allowlist. A line that matches a FORBIDDEN_PATTERN is treated +# as a false positive in any of these cases: +# 1. The line itself contains the marker. +# 2. The line *immediately above* contains the marker (handy for a +# single-line trigger where the marker doesn't fit on the line). +# 3. The line falls inside a " start" ... " end" block +# (handy for multi-line clumps — same convention as gitleaks). +# Use sparingly — only on lines that genuinely don't hold a secret (prose +# comments, test fixtures with placeholder values, constant strings). +ALLOWLIST_MARKER='pragma: allowlist secret' + +errors=0 + +for file in $(git diff --cached --name-only --diff-filter=ACM); do + skip=false + for pat in "${SKIP_FILES[@]}"; do + # shellcheck disable=SC2053 + if [[ "$file" == $pat ]]; then + skip=true + break + fi + done + [[ "$skip" == true ]] && continue + + blob=$(git show ":$file" 2>/dev/null) || continue + + # Walk the file once to compute the set of allowlisted line numbers. + # Tracks both "single-line marker" and " start/end" block state. + declare -A allowlisted=() + in_block=false + prev_was_marker=false + line_num=0 + while IFS= read -r line_content; do + line_num=$((line_num + 1)) + if [[ "$line_content" == *"$ALLOWLIST_MARKER start"* ]]; then + in_block=true + prev_was_marker=false + continue + fi + if [[ "$line_content" == *"$ALLOWLIST_MARKER end"* ]]; then + in_block=false + prev_was_marker=false + continue + fi + is_marker_line=false + if [[ "$line_content" == *"$ALLOWLIST_MARKER"* ]]; then + is_marker_line=true + fi + if [[ "$in_block" == true \ + || "$is_marker_line" == true \ + || "$prev_was_marker" == true ]]; then + allowlisted[$line_num]=1 + fi + prev_was_marker=$is_marker_line + done <<<"$blob" + + for pattern in "${FORBIDDEN_PATTERNS[@]}"; do + while IFS= read -r match; do + [[ -z "$match" ]] && continue + line_num="${match%%:*}" + [[ -n "${allowlisted[$line_num]:-}" ]] && continue + echo "ERROR: potential secret in $file:$line_num (pattern: $pattern)" + errors=$((errors + 1)) + done < <(grep -niE "$pattern" <<<"$blob" || true) + done + unset allowlisted +done + +# Unencrypted or malformed sops files. +# Structural check: a real sops YAML always contains both a top-level `sops:` +# block AND a `mac:` field whose value is `ENC[...]`. Either signal alone is +# trivially forgeable; together they're specific to actual sops output. +for file in $(git diff --cached --name-only --diff-filter=ACM | grep -E 'secrets.*\.yaml$' || true); do + blob=$(git show ":$file" 2>/dev/null) + if ! grep -q '^[[:space:]]*sops:' <<<"$blob"; then + echo "ERROR: unencrypted secrets file: $file (no sops metadata block)" + echo " run: sops -e -i $file" + errors=$((errors + 1)) + elif ! grep -q '^[[:space:]]*mac: ENC\[' <<<"$blob"; then + echo "ERROR: secrets file has sops block but mac is not encrypted: $file" + echo " file may be tampered or partially decrypted; re-encrypt with sops" + errors=$((errors + 1)) + fi +done + +if (( errors > 0 )); then + echo "" + echo "commit blocked: $errors potential secret(s) detected" + echo "false positive? add '# $ALLOWLIST_MARKER' on/above the line," + echo "or wrap a block with '# $ALLOWLIST_MARKER start' ... '# $ALLOWLIST_MARKER end'," + echo "or bypass with: git commit --no-verify" + exit 1 +fi + +exit 0 diff --git a/modules/dev-env/scripts/lib-colors.sh b/modules/dev-env/scripts/lib-colors.sh new file mode 100644 index 0000000..46faf8c --- /dev/null +++ b/modules/dev-env/scripts/lib-colors.sh @@ -0,0 +1,17 @@ +# dev-env — shared ANSI colour palette. +# +# Installed at /etc/dev-env/lib-colors.sh and sourced by the standalone +# bins (bootstrap / rebase / status). Superset of the codes each script +# used before they were consolidated here. Plain assignments (not +# readonly) so re-sourcing is harmless. Each script keeps its own log +# helpers (info/ok/warn/… formats differ by tool); only the constants +# are shared. + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +BOLD='\033[1m' +DIM='\033[2m' +NC='\033[0m' diff --git a/modules/dev-env/scripts/lib.sh b/modules/dev-env/scripts/lib.sh new file mode 100644 index 0000000..4139204 --- /dev/null +++ b/modules/dev-env/scripts/lib.sh @@ -0,0 +1,24 @@ +# dev-env — shared config-loader library. +# +# Installed at /etc/dev-env/lib.sh and sourced by the shell-function +# modules (nav / worktree / pr-helpers / regtest). Holds the runtime +# config loader that was previously copy-pasted into each module. +# +# Side-effect-free at top level (function definitions only): it is +# sourced into interactive shells and may be re-sourced freely, so it +# must stay idempotent and must not clobber the user's environment. +# Colours deliberately live in lib-colors.sh (sourced only by the +# standalone bins) so they don't leak into interactive shells. + +# Load the rendered runtime config (DEV_ROOT, REPOS_DIR, deploy targets, +# …) into the current shell. Safe to call repeatedly. +_devenv_source_config() { + if [[ -r /etc/dev-env/config.sh ]]; then + # shellcheck disable=SC1091 + source /etc/dev-env/config.sh + fi +} + +# Default loader used by the navigation / worktree / PR modules. +# regtest.sh overrides this to layer on its own path defaults. +_devenv_load_config() { _devenv_source_config; } diff --git a/modules/dev-env/scripts/nav.sh b/modules/dev-env/scripts/nav.sh new file mode 100644 index 0000000..1711158 --- /dev/null +++ b/modules/dev-env/scripts/nav.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +# dev-env: navigation helpers +# +# Sourced by user shells (not invoked as a script) — placed in +# /etc/profile.d/dev-env-nav.sh by config.nix. Every function reads +# /etc/dev-env/config.sh at call time so adding a new worktree on disk +# is immediately visible without a nixos-rebuild. +# +# Bugfix vs ~/dev/.devenv.d/00-navigation.sh: the original defined +# `ln() { sn lamassu-next; }` which shadows /usr/bin/ln. That's a real +# footgun — interactive shells could no longer create symlinks without +# `command ln`. We drop `ln` and add `lam` instead. + +# Shared config loader (_devenv_load_config) lives in /etc/dev-env/lib.sh. +if [[ -r /etc/dev-env/lib.sh ]]; then + # shellcheck disable=SC1091 + source /etc/dev-env/lib.sh +fi + +# Navigate to the dev root. +dev() { + _devenv_load_config + cd "$DEV_ROOT" || return 1 +} + +# Navigate to an lnbits worktree. +# Usage: lb [env] env ∈ {dev, main} or whatever the filesystem shows +lb() { + _devenv_load_config + local env="${1:-}" + local lnbits_dir="${LNBITS_DIR:-$DEV_ROOT/lnbits}" + + if [[ -z "$env" ]]; then + echo "Usage: lb " + echo "" + echo "Current lnbits worktrees:" + if [[ -d "$lnbits_dir" ]]; then + for d in "$lnbits_dir"/*/; do + [[ -d "$d" ]] || continue + local name branch + name="$(basename "$d")" + branch="$(git -C "$d" branch --show-current 2>/dev/null || echo '?')" + printf " %-12s (%s)\n" "$name" "$branch" + done + fi + return 0 + fi + + if [[ -d "$lnbits_dir/$env" ]]; then + cd "$lnbits_dir/$env" || return 1 + else + echo "Unknown lnbits worktree: $env" + return 1 + fi +} + +# Navigate to a webapp target branch worktree. +wa() { + _devenv_load_config + local target="${1:-}" + local webapp_dir="${WEBAPP_DIR:-$DEV_ROOT/webapp}" + + if [[ -z "$target" ]]; then + echo "Usage: wa " + echo "" + echo "Current webapp worktrees:" + if [[ -d "$webapp_dir" ]]; then + for d in "$webapp_dir"/*/; do + [[ -d "$d" ]] || continue + local name branch + name="$(basename "$d")" + branch="$(git -C "$d" branch --show-current 2>/dev/null || echo '?')" + printf " %-12s (%s)\n" "$name" "$branch" + done + fi + return 0 + fi + + if [[ -d "$webapp_dir/$target" ]]; then + cd "$webapp_dir/$target" || return 1 + else + echo "Unknown webapp target: $target" + return 1 + fi +} + +# Navigate within a project-group folder (e.g. bitspire/, lamassu-next/). +# Sub-repos may be single clones or bare-repo worktree sets; worktree +# sets accept an optional worktree name (defaults to dev). +_dev_group_nav() { + local group_dir="$1" repo="${2:-}" worktree="${3:-dev}" + + if [[ -z "$repo" ]]; then + echo "repos under $(basename "$group_dir")/:" + if [[ -d "$group_dir" ]]; then + for d in "$group_dir"/*/; do + [[ -d "$d" ]] && echo " $(basename "$d")" + done + fi + return 0 + fi + + # Single-clone sub-repo (has its own .git at the top level) + if [[ -d "$group_dir/$repo/.git" ]]; then + cd "$group_dir/$repo" || return 1 + return 0 + fi + + # Worktree-based sub-repo + if [[ -d "$group_dir/$repo/$worktree" ]]; then + cd "$group_dir/$repo/$worktree" || return 1 + elif [[ -d "$group_dir/$repo" ]]; then + cd "$group_dir/$repo" || return 1 + else + echo "Unknown repo under $(basename "$group_dir")/: $repo" + return 1 + fi +} + +# bitSpire ATM stack — ~/dev/bitspire/{bitspire,atm-tui} +# Usage: bs [repo] [worktree] (defaults: bs bitspire dev) +bs() { + _devenv_load_config + _dev_group_nav "${BITSPIRE_DIR:-$DEV_ROOT/bitspire}" "${1:-bitspire}" "${2:-dev}" +} + +# lamassu-next legacy ATM stack — ~/dev/lamassu-next/{lamassu-next,lightning-pub} +# Usage: lam [repo] [worktree] (defaults: lam lamassu-next dev) +lam() { + _devenv_load_config + _dev_group_nav "${LAMASSU_NEXT_DIR:-$DEV_ROOT/lamassu-next}" "${1:-lamassu-next}" "${2:-dev}" +} + +# Navigate to an extension under shared/extensions. +ext() { + _devenv_load_config + local extension="${1:-}" + local ext_root="${SHARED_DIR:-$DEV_ROOT/shared}/extensions" + + if [[ -z "$extension" ]]; then + echo "Available extensions:" + [[ -d "$ext_root" ]] && ls -1 "$ext_root" + return 0 + fi + if [[ -d "$ext_root/$extension" ]]; then + cd "$ext_root/$extension" || return 1 + else + echo "Unknown extension: $extension" + [[ -d "$ext_root" ]] && ls -1 "$ext_root" + return 1 + fi +} + +# Navigate to a deploy host config inside the deploy flake working copy. +deploy_nav() { + _devenv_load_config + local host="${1:-}" + local deploy_root="${DEPLOY_DIR:-$DEV_ROOT/deploy}" + + if [[ -z "$host" ]]; then + echo "Usage: deploy " + echo "" + echo "Deploy targets (from /etc/dev-env/config.sh):" + # DEPLOY_TARGETS is an array exported by config.sh + if [[ -n "${DEPLOY_TARGETS[*]:-}" ]]; then + printf ' %s\n' "${DEPLOY_TARGETS[@]}" + fi + return 1 + fi + + # Prefer a per-host config dir if present; otherwise the unified root + if [[ -d "$deploy_root/unified/hosts/$host" ]]; then + cd "$deploy_root/unified/hosts/$host" || return 1 + elif [[ -d "$deploy_root/unified" ]]; then + cd "$deploy_root/unified" || return 1 + elif [[ -d "$deploy_root/$host" ]]; then + cd "$deploy_root/$host" || return 1 + else + echo "Deploy path not found: $deploy_root/{unified,}" + return 1 + fi +} +# Rename to avoid colliding with the `deploy` function from the legacy +# script if it's still sourced during migration. `dep` is free. +alias dep=deploy_nav + +# Navigate to the shared repos directory. +shared() { + _devenv_load_config + cd "${SHARED_DIR:-$DEV_ROOT/shared}" || return 1 +} + +# Navigate to the bare-repos directory. +repos() { + _devenv_load_config + cd "${REPOS_DIR:-$DEV_ROOT/repos}" || return 1 +} + +# Navigate to the upstream-prs directory. +prs() { + _devenv_load_config + cd "${UPSTREAM_PRS_DIR:-$DEV_ROOT/upstream-prs}" || return 1 +} diff --git a/modules/dev-env/scripts/pr-helpers.sh b/modules/dev-env/scripts/pr-helpers.sh new file mode 100644 index 0000000..8836096 --- /dev/null +++ b/modules/dev-env/scripts/pr-helpers.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# dev-env: upstream PR worktree helpers +# +# Provides prb / prc / prl. Each PR gets a throwaway worktree at +# ${UPSTREAM_PRS_DIR}/- based on upstream/main (or +# master), ready to push to the `github-fork` remote and open a PR. +# +# Ported from ~/dev/.devenv.d/90-upstream-prs.sh; reads paths from +# /etc/dev-env/config.sh. + +# Shared config loader (_devenv_load_config) lives in /etc/dev-env/lib.sh. +if [[ -r /etc/dev-env/lib.sh ]]; then + # shellcheck disable=SC1091 + source /etc/dev-env/lib.sh +fi + +# Create a PR worktree at ${UPSTREAM_PRS_DIR}/-. +# +# Usage: git-pr-branch +git-pr-branch() { + _devenv_load_config + local repo_name="${1:-}" + local branch_name="${2:-}" + local repos_dir="${REPOS_DIR:-$DEV_ROOT/repos}" + local prs_dir="${UPSTREAM_PRS_DIR:-$DEV_ROOT/upstream-prs}" + + if [[ -z "$repo_name" || -z "$branch_name" ]]; then + echo "Usage: git-pr-branch " + echo "Example: git-pr-branch lnbits fix-invoice-bug" + echo "" + echo "Creates a worktree at $prs_dir/-" + echo "based on upstream/main, ready for an upstream PR." + return 1 + fi + + local bare_repo="$repos_dir/${repo_name}.git" + local pr_path="$prs_dir/${repo_name}-${branch_name}" + + if [[ ! -d "$bare_repo" ]]; then + echo "Repo not found: $bare_repo" + echo "Available repos:" + ls -1 "$repos_dir"/*.git 2>/dev/null | xargs -n1 basename | sed 's/\.git$//' + return 1 + fi + + if [[ -d "$pr_path" ]]; then + echo "PR worktree already exists: $pr_path" + echo "To remove it: git-pr-cleanup $repo_name $branch_name" + return 1 + fi + + # Fetch upstream + echo "Fetching upstream..." + git -C "$bare_repo" fetch upstream 2>/dev/null || { + echo "No 'upstream' remote on $repo_name — cannot create PR branch" + return 1 + } + + # Determine base branch (main or master) + local base_branch="main" + git -C "$bare_repo" show-ref --verify --quiet "refs/remotes/upstream/main" \ + || base_branch="master" + + echo "Creating branch '$branch_name' from upstream/$base_branch..." + git -C "$bare_repo" branch "$branch_name" "upstream/$base_branch" 2>/dev/null \ + || git -C "$bare_repo" branch -f "$branch_name" "upstream/$base_branch" + + mkdir -p "$prs_dir" + git -C "$bare_repo" worktree add "$pr_path" "$branch_name" + + if ! git -C "$bare_repo" remote get-url github-fork &>/dev/null; then + echo "" + echo "Note: 'github-fork' remote not configured for $repo_name." + echo "Add it with:" + echo " git -C $bare_repo remote add github-fork git@github.com:${GITHUB_FORK_USER:-}/${repo_name}.git" + fi + + cat < " + echo "" + echo "Active PR worktrees:" + ls -1 "$prs_dir" 2>/dev/null || echo " (none)" + return 1 + fi + + local bare_repo="$repos_dir/${repo_name}.git" + local pr_path="$prs_dir/${repo_name}-${branch_name}" + + [[ -d "$pr_path" ]] || { echo "PR worktree not found: $pr_path"; return 1; } + + echo "Removing worktree: $pr_path" + git -C "$bare_repo" worktree remove "$pr_path" + + echo "Deleting branch: $branch_name" + if ! git -C "$bare_repo" branch -d "$branch_name" 2>/dev/null; then + echo " Branch '$branch_name' is not fully merged." + read -r -p " Force-delete it (unmerged commits will be lost)? [y/N] " -n 1 REPLY + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + git -C "$bare_repo" branch -D "$branch_name" + else + echo " Kept branch '$branch_name' (worktree already removed)." + return 1 + fi + fi + + echo "Done." +} + +# List all active PR worktrees. +git-pr-list() { + _devenv_load_config + local prs_dir="${UPSTREAM_PRS_DIR:-$DEV_ROOT/upstream-prs}" + + echo "=== Active PR Worktrees ===" + if [[ -d "$prs_dir" && -n "$(ls -A "$prs_dir" 2>/dev/null)" ]]; then + for pr_dir in "$prs_dir"/*; do + [[ -d "$pr_dir" ]] || continue + local name branch status + name="$(basename "$pr_dir")" + branch="$(git -C "$pr_dir" branch --show-current 2>/dev/null || echo '?')" + status="$(git -C "$pr_dir" status -sb 2>/dev/null | head -1 || echo '?')" + printf " %-40s %s %s\n" "$name" "$branch" "$status" + done + else + echo " (none)" + fi +} + +alias prb='git-pr-branch' +alias prc='git-pr-cleanup' +alias prl='git-pr-list' diff --git a/modules/dev-env/scripts/rebase.sh b/modules/dev-env/scripts/rebase.sh new file mode 100644 index 0000000..7268f43 --- /dev/null +++ b/modules/dev-env/scripts/rebase.sh @@ -0,0 +1,410 @@ +#!/usr/bin/env bash +# dev-env: safe fork-onto-upstream rebase helper +# +# Ported with minor adaptation from +# ~/omarchy-dev-env/setup/rebase-helper.sh. Changes: +# - Sources /etc/dev-env/config.sh instead of ~/dev/.devenv.conf +# - Walks projects from the runtime config instead of a hard-coded +# PROJECTS_DIR that no longer exists in the current layout +# - Log file lives under the user's XDG state dir, not ~/dev/ +# +# Workflow for a single rebase: +# 1. Safety checks (no uncommitted changes, upstream remote exists) +# 2. Create backup branch `backup/pre-rebase-YYYYMMDD-HHMMSS` +# 3. Show incoming + outgoing commits +# 4. Rebase and force-with-lease push (with confirmation) +# 5. On conflict, print resolution guide and preserve backup + +set -e + +# Load runtime config (sets DEV_ROOT, REPOS_DIR, SHARED_DIR, …) +if [[ -r /etc/dev-env/config.sh ]]; then + # shellcheck disable=SC1091 + source /etc/dev-env/config.sh +elif [[ -r "$HOME/dev/.devenv.conf" ]]; then + # shellcheck disable=SC1091 + source "$HOME/dev/.devenv.conf" +else + echo "Error: neither /etc/dev-env/config.sh nor ~/dev/.devenv.conf found" >&2 + exit 1 +fi + +DEV_ROOT="${DEV_ROOT:-$HOME/dev}" +REPOS_DIR="${REPOS_DIR:-$DEV_ROOT/repos}" +SHARED_DIR="${SHARED_DIR:-$DEV_ROOT/shared}" + +BACKUP_PREFIX="backup/pre-rebase" +LOG_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/dev-env/rebase.log" +mkdir -p "$(dirname "$LOG_FILE")" + +#------------------------------------------------------------------------------- +# Colors — shared palette (RED/GREEN/YELLOW/BLUE/CYAN/BOLD/NC …) +#------------------------------------------------------------------------------- +if [[ -r /etc/dev-env/lib-colors.sh ]]; then + # shellcheck disable=SC1091 + source /etc/dev-env/lib-colors.sh +fi + +info() { echo -e "${BLUE}[INFO]${NC} $1"; } +success() { echo -e "${GREEN}[OK]${NC} $1"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +error() { echo -e "${RED}[ERROR]${NC} $1"; } +header() { echo -e "\n${BOLD}${CYAN}═══ $1 ═══${NC}\n"; } + +log_action() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE" +} + +confirm() { + local prompt="${1:-Continue?}" + read -r -p "$prompt (y/N) " -n 1 REPLY + echo + [[ $REPLY =~ ^[Yy]$ ]] +} + +#------------------------------------------------------------------------------- +# Generic repo helpers +#------------------------------------------------------------------------------- +get_upstream_branch() { + local repo_path="$1" + (cd "$repo_path" + for branch in main master; do + if git rev-parse "upstream/$branch" &>/dev/null; then + echo "$branch" + return 0 + fi + done + echo "main") +} + +check_repo_clean() { + local repo_path="$1" + [[ -z "$(git -C "$repo_path" status --porcelain)" ]] +} + +has_upstream() { + git -C "$1" remote get-url upstream &>/dev/null +} + +create_backup() { + local repo_path="$1" + local branch + branch="$(git -C "$repo_path" branch --show-current)" + local backup_name="${BACKUP_PREFIX}-$(date +%Y%m%d-%H%M%S)" + git -C "$repo_path" branch -f "$backup_name" "$branch" + echo "$backup_name" +} + +show_divergence() { + local repo_path="$1" upstream_branch="$2" + local behind ahead + behind=$(git -C "$repo_path" rev-list --count "HEAD..upstream/$upstream_branch" 2>/dev/null || echo 0) + ahead=$(git -C "$repo_path" rev-list --count "upstream/$upstream_branch..HEAD" 2>/dev/null || echo 0) + echo -e " ${CYAN}Behind upstream:${NC} $behind commits" + echo -e " ${GREEN}Ahead (your work):${NC} $ahead commits" +} + +#------------------------------------------------------------------------------- +# Core rebase +#------------------------------------------------------------------------------- +rebase_single_repo() { + local repo_path="$1" repo_name="$2" + local upstream_branch="${3:-}" + local auto_mode="${4:-false}" + + header "Rebasing: $repo_name" + + if [[ ! -d "$repo_path/.git" ]] && [[ ! -f "$repo_path/.git" ]]; then + error "Not a git repository: $repo_path" + return 1 + fi + + if ! has_upstream "$repo_path"; then + warn "No upstream remote configured. Skipping." + return 0 + fi + + if ! check_repo_clean "$repo_path"; then + error "Uncommitted changes detected!" + echo " Stash or commit first: git stash / git commit" + return 1 + fi + + [[ -z "$upstream_branch" ]] && upstream_branch="$(get_upstream_branch "$repo_path")" + + local current_branch + current_branch="$(git -C "$repo_path" branch --show-current)" + info "Current branch: $current_branch" + info "Upstream branch: upstream/$upstream_branch" + + info "Fetching upstream..." + git -C "$repo_path" fetch upstream + + show_divergence "$repo_path" "$upstream_branch" + + local behind + behind=$(git -C "$repo_path" rev-list --count "HEAD..upstream/$upstream_branch" 2>/dev/null || echo 0) + if [[ "$behind" -eq 0 ]]; then + success "Already up to date with upstream!" + return 0 + fi + + echo "" + echo -e "${BOLD}New commits from upstream:${NC}" + git -C "$repo_path" log --oneline --graph "HEAD..upstream/$upstream_branch" | head -15 + local total_upstream + total_upstream="$(git -C "$repo_path" rev-list --count "HEAD..upstream/$upstream_branch")" + (( total_upstream > 15 )) && echo " ... and $((total_upstream - 15)) more commits" + + echo "" + echo -e "${BOLD}Your commits to replay:${NC}" + git -C "$repo_path" log --oneline "upstream/$upstream_branch..HEAD" | head -15 + local total_yours + total_yours="$(git -C "$repo_path" rev-list --count "upstream/$upstream_branch..HEAD")" + (( total_yours > 15 )) && echo " ... and $((total_yours - 15)) more commits" + + echo "" + if [[ "$auto_mode" != "true" ]] && ! confirm "Proceed with rebase?"; then + warn "Skipped." + return 0 + fi + + local backup_branch + backup_branch="$(create_backup "$repo_path")" + success "Backup created: $backup_branch" + + info "Rebasing onto upstream/$upstream_branch..." + if git -C "$repo_path" rebase "upstream/$upstream_branch"; then + success "Rebase completed successfully!" + echo "" + if confirm "Push to origin with --force-with-lease?"; then + git -C "$repo_path" push origin "$current_branch" --force-with-lease + success "Pushed to origin!" + log_action "REBASE SUCCESS: $repo_name onto upstream/$upstream_branch" + else + warn "Not pushed. When ready: git push origin $current_branch --force-with-lease" + fi + echo "" + info "Backup branch '$backup_branch' preserved." + echo " Delete later: git branch -D $backup_branch" + return 0 + else + error "Rebase encountered conflicts!" + cat <>>>>>> + 3. Stage: ${CYAN}git add ${NC} + 4. Continue: ${CYAN}git rebase --continue${NC} + 5. Skip empty: ${CYAN}git rebase --skip${NC} + 6. Abort: ${CYAN}git rebase --abort${NC} + 7. After success: ${CYAN}git push origin $current_branch --force-with-lease${NC} + + Backup: ${GREEN}$backup_branch${NC} +EOF + log_action "REBASE CONFLICT: $repo_name - manual resolution required" + return 1 + fi +} + +#------------------------------------------------------------------------------- +# Discovery: walk actual worktrees under $DEV_ROOT instead of the old +# $PROJECTS_DIR layout that no longer exists. +#------------------------------------------------------------------------------- +find_forked_repos() { + # Print one ":" per rebase-eligible repo (has + # upstream remote). We look at every git dir (.git dir or .git file + # for worktrees) under $DEV_ROOT, excluding $REPOS_DIR bare repos. + find "$DEV_ROOT" -type d -name '.git' -prune 2>/dev/null | while read -r gitdir; do + local repo_path + repo_path="$(dirname "$gitdir")" + # Skip bare repos under REPOS_DIR + [[ "$repo_path" == "$REPOS_DIR"* ]] && continue + # Skip node_modules + [[ "$repo_path" == *"/node_modules/"* ]] && continue + if has_upstream "$repo_path"; then + local display + display="${repo_path#$DEV_ROOT/}" + echo "$repo_path:$display" + fi + done + # Also bare repos under REPOS_DIR (the .git file case) + find "$DEV_ROOT" -type f -name '.git' 2>/dev/null | while read -r gitfile; do + local repo_path + repo_path="$(dirname "$gitfile")" + [[ "$repo_path" == *"/node_modules/"* ]] && continue + if has_upstream "$repo_path"; then + local display + display="${repo_path#$DEV_ROOT/}" + echo "$repo_path:$display" + fi + done +} + +#------------------------------------------------------------------------------- +# Batch modes +#------------------------------------------------------------------------------- +rebase_all() { + header "Rebasing ALL Forks with Upstreams" + warn "This walks $DEV_ROOT for every worktree with an 'upstream' remote." + echo "" + if ! confirm "Continue?"; then info "Aborted."; return 0; fi + + local failed=() + while IFS=: read -r path name; do + if ! rebase_single_repo "$path" "$name"; then + failed+=("$name") + fi + done < <(find_forked_repos) + + echo "" + header "Summary" + if (( ${#failed[@]} == 0 )); then + success "All repositories rebased successfully!" + else + error "Failed (${#failed[@]}):" + printf ' - %s\n' "${failed[@]}" + return 1 + fi +} + +show_all_status() { + header "Repository Rebase Status" + while IFS=: read -r path name; do + local upstream_branch behind ahead + upstream_branch="$(get_upstream_branch "$path")" + git -C "$path" fetch upstream --quiet 2>/dev/null || true + behind=$(git -C "$path" rev-list --count "HEAD..upstream/$upstream_branch" 2>/dev/null || echo ?) + ahead=$(git -C "$path" rev-list --count "upstream/$upstream_branch..HEAD" 2>/dev/null || echo ?) + printf " %-40s ↓%-3s ↑%-3s\n" "$name" "$behind" "$ahead" + done < <(find_forked_repos) + echo "" + echo -e "${CYAN}Legend:${NC} ↓=behind upstream ↑=ahead (your commits)" +} + +view_log() { + header "Rebase Log" + if [[ -f "$LOG_FILE" ]]; then tail -50 "$LOG_FILE" + else info "No rebase log yet at $LOG_FILE"; fi +} + +cleanup_backups() { + header "Cleanup Backup Branches" + echo "Options:" + echo " 1) Delete backups older than 7 days" + echo " 2) Delete backups older than 30 days" + echo " 3) Delete ALL backup branches" + echo " 4) Cancel" + read -r -p "Select option: " -n 1 REPLY; echo "" + + local days=0 + case $REPLY in + 1) days=7 ;; + 2) days=30 ;; + 3) days=0 ;; + *) info "Cancelled."; return 0 ;; + esac + + local cutoff="" + (( days > 0 )) && cutoff=$(date -d "$days days ago" +%Y%m%d 2>/dev/null || date -v-"${days}d" +%Y%m%d) + + local deleted=0 + while IFS=: read -r path _; do + cd "$path" || continue + while read -r branch; do + branch="$(echo "$branch" | tr -d ' *')" + local bdate + bdate="$(echo "$branch" | grep -oE '[0-9]{8}' | head -1)" + if [[ $days -eq 0 ]] \ + || { [[ -n "$bdate" ]] && [[ "$bdate" < "$cutoff" ]]; }; then + git branch -D "$branch" 2>/dev/null && deleted=$((deleted + 1)) || true + fi + done < <(git branch --list "backup/*" 2>/dev/null) + done < <(find_forked_repos) + success "Deleted $deleted backup branch(es)." +} + +#------------------------------------------------------------------------------- +# Interactive / CLI +#------------------------------------------------------------------------------- +interactive_menu() { + while true; do + header "Rebase Helper" + echo " 1) Rebase a single repository (by path)" + echo " 2) Rebase ALL forks with upstream" + echo " s) Status of all repos" + echo " l) View rebase log" + echo " c) Clean up old backup branches" + echo " q) Quit" + echo "" + read -r -p "Select: " -n 1 REPLY; echo "" + case $REPLY in + 1) read -r -p "Repo path: " p + [[ -d "$p/.git" || -f "$p/.git" ]] && rebase_single_repo "$p" "$(basename "$p")" || warn "Not a repo: $p" ;; + 2) rebase_all ;; + s) show_all_status ;; + l) view_log ;; + c) cleanup_backups ;; + q) echo "Bye!"; exit 0 ;; + *) warn "Invalid option" ;; + esac + echo "" + read -r -p "Press Enter to continue..." + done +} + +usage() { + cat < Rebase a single repository + rebase all Rebase all forks with upstream remote + rebase status Show status of all forks + rebase log Show rebase log + rebase cleanup Clean up old backup branches + +OPTIONS: + -b, --branch Upstream branch to rebase onto + -y, --yes Auto-confirm prompts + -h, --help Show this help + +Backups are branched automatically as backup/pre-rebase-YYYYMMDD-HHMMSS +and never deleted without explicit cleanup. +EOF +} + +main() { + local command="" repo_path="" upstream_branch="" auto_yes=false + while [[ $# -gt 0 ]]; do + case $1 in + -h|--help) usage; exit 0 ;; + -b|--branch) upstream_branch="$2"; shift 2 ;; + -y|--yes) auto_yes=true; shift ;; + single|all|status|log|cleanup) command="$1"; shift ;; + *) + if [[ -z "$command" ]] && [[ -d "$1/.git" || -f "$1/.git" ]]; then + command="single"; repo_path="$1" + else + repo_path="$1" + fi + shift + ;; + esac + done + + case $command in + "") interactive_menu ;; + single) [[ -z "$repo_path" ]] && { error "Need path"; exit 1; } + rebase_single_repo "$repo_path" "$(basename "$repo_path")" "$upstream_branch" "$auto_yes" ;; + all) rebase_all ;; + status) show_all_status ;; + log) view_log ;; + cleanup) cleanup_backups ;; + *) error "Unknown command: $command"; usage; exit 1 ;; + esac +} + +main "$@" diff --git a/modules/dev-env/scripts/regtest.sh b/modules/dev-env/scripts/regtest.sh new file mode 100644 index 0000000..e67ede2 --- /dev/null +++ b/modules/dev-env/scripts/regtest.sh @@ -0,0 +1,284 @@ +#!/usr/bin/env bash +# dev-env: Bitcoin/Lightning regtest docker environment +# +# Provides: +# regtest-start [env|pr:] [--seed|--keep] +# regtest-stop +# regtest-status +# regtest-logs [service] +# regtest-cli # source CLI helpers +# regtest # cd to regtest dir +# +# Ported from ~/dev/.devenv.d/40-regtest.sh. Reads paths from +# /etc/dev-env/config.sh. The bitSpire ATM reaches its Lightning +# backend through the LNbits nostr-native transport (the nostrrelay +# extension) in this same regtest — there is no separate ATM-backend +# container to start. +# +# This is sourced into interactive shells (function definitions) AND +# also dropped into the system path as standalone wrapper scripts so +# `regtest-start` works from a fresh non-interactive shell. + +# Shared base config loader lives in /etc/dev-env/lib.sh; this module +# layers regtest-specific path defaults on top. +if [[ -r /etc/dev-env/lib.sh ]]; then + # shellcheck disable=SC1091 + source /etc/dev-env/lib.sh +fi + +_devenv_load_config() { + _devenv_source_config + REGTEST_DIR="${LOCAL_DIR:-$DEV_ROOT/local}/docker/regtest" + LNBITS_DIR="${LNBITS_DIR:-$DEV_ROOT/lnbits}" + UPSTREAM_PRS_DIR="${UPSTREAM_PRS_DIR:-$DEV_ROOT/upstream-prs}" +} + +#------------------------------------------------------------------------------- +# Status helpers +#------------------------------------------------------------------------------- + +_regtest_is_running() { + docker ps --filter "name=lnbits-lnd-4-1" --format '{{.Names}}' 2>/dev/null \ + | grep -q lnbits-lnd-4-1 +} + +_wait_for_lnd4() { + local attempts=0 + echo "Waiting for lnd-4..." + while ! docker exec lnbits-lnd-4-1 lncli --network=regtest --rpcserver=lnd-4:10009 getinfo &>/dev/null; do + if (( attempts >= 30 )); then + echo "lnd-4 failed to start" + return 1 + fi + sleep 2 + ((attempts++)) + done + echo "lnd-4 is ready" +} + +#------------------------------------------------------------------------------- +# Start +#------------------------------------------------------------------------------- +# Usage: regtest-start [env|pr:] [--path ] [--seed|--keep] +# +# env dev|main (worktree under ~/dev/lnbits/) +# pr: ~/dev/upstream-prs/lnbits- +# --path arbitrary lnbits directory +# --seed copy lnbits-seed/ to lnbits/ before starting +# --keep keep existing data +regtest-start() { + _devenv_load_config + + local env="" data_mode="fresh" custom_path="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --seed) data_mode="seed" ;; + --keep) data_mode="keep" ;; + --path) shift; custom_path="$1" ;; + dev|main) env="$1" ;; + pr:*) + local pr_branch="${1#pr:}" + custom_path="$UPSTREAM_PRS_DIR/lnbits-$pr_branch" + ;; + *) + cat <] [--path ] [--seed|--keep] + +Environments: + dev, main ~/dev/lnbits/ + pr: ~/dev/upstream-prs/lnbits- + --path arbitrary lnbits directory +USAGE + return 1 + ;; + esac + shift + done + + if [[ ! -d "$REGTEST_DIR" ]]; then + echo "Regtest environment not found at $REGTEST_DIR" + echo "Run dev-env-bootstrap to clone it." + return 1 + fi + + # Determine lnbits source dir + local lnbits_path="" + if [[ -n "$custom_path" ]]; then + lnbits_path="$custom_path" + [[ -d "$lnbits_path" ]] || { echo "lnbits dir not found: $lnbits_path"; return 1; } + else + [[ -z "$env" ]] && env="dev" + lnbits_path="$LNBITS_DIR/$env" + [[ -d "$lnbits_path" ]] || { echo "lnbits worktree not found: $env"; return 1; } + fi + + local lnbits_data="$REGTEST_DIR/data/lnbits" + local lnbits_seed="$REGTEST_DIR/data/lnbits-seed" + + case "$data_mode" in + fresh) + if [[ -d "$lnbits_data" ]] && [[ -n "$(ls -A "$lnbits_data" 2>/dev/null)" ]]; then + echo "Existing lnbits data at $lnbits_data" + read -r -p "Wipe it? [y/N] " -n 1 REPLY; echo + [[ $REPLY =~ ^[Yy]$ ]] || { echo "Aborted."; return 1; } + docker run --rm -v "$lnbits_data":/data alpine sh -c "rm -rf /data/* /data/.*" 2>/dev/null || true + rmdir "$lnbits_data" 2>/dev/null || true + fi + mkdir -p "$lnbits_data" + ;; + seed) + [[ -d "$lnbits_seed" ]] || { echo "Seed data not found at $lnbits_seed"; return 1; } + if [[ -d "$lnbits_data" ]] && [[ -n "$(ls -A "$lnbits_data" 2>/dev/null)" ]]; then + read -r -p "Overwrite with seed? [y/N] " -n 1 REPLY; echo + [[ $REPLY =~ ^[Yy]$ ]] || { echo "Aborted."; return 1; } + docker run --rm -v "$lnbits_data":/data alpine sh -c "rm -rf /data/* /data/.*" 2>/dev/null || true + rmdir "$lnbits_data" 2>/dev/null || true + fi + cp -r "$lnbits_seed" "$lnbits_data" + ;; + keep) + mkdir -p "$lnbits_data" + ;; + esac + + echo "" + echo "Starting regtest..." + echo " lnbits path: $lnbits_path" + echo " data mode: $data_mode" + echo "" + + echo "Building lnbits Docker image from $lnbits_path..." + docker build -t lnbits/lnbits "$lnbits_path" + + (cd "$REGTEST_DIR" && ./start-regtest) + + cat < LND node CLI + lightning-cli-sim <1-3> C-Lightning node CLI + boltzcli-sim Boltz client CLI + elements-cli-sim Elements/Liquid CLI + +Examples: + bitcoin-cli-sim -generate 1 + lncli-sim 1 getinfo +EOF +} + +regtest() { + _devenv_load_config + [[ -d "$REGTEST_DIR" ]] && cd "$REGTEST_DIR" || { echo "Regtest not found at $REGTEST_DIR"; return 1; } +} + +#------------------------------------------------------------------------------- +# Rebuild / restart the lnbits service (docker-compose.dev.yml workflow) +#------------------------------------------------------------------------------- +# regtest-lnbits-rebuild build (cached) + recreate the lnbits container +# regtest-lnbits-rebuild --clean build --no-cache + recreate (truly fresh image) +# regtest-lnbits-restart restart the container without rebuilding +# +# The cached build is the happy path: docker invalidates the source COPY layer +# when LNBITS_SRC content changes, so most rebuilds are fast. Use --clean when +# you've been mucking with the image itself. + +_regtest_lnbits_compose_file() { + echo "$REGTEST_DIR/docker-compose.dev.yml" +} + +regtest-lnbits-rebuild() { + _devenv_load_config + local compose_file build_args=() + compose_file="$(_regtest_lnbits_compose_file)" + [[ -f "$compose_file" ]] || { echo "Compose file not found: $compose_file"; return 1; } + + if [[ "${1:-}" == "--clean" ]]; then + build_args+=(--no-cache) + fi + + (cd "$REGTEST_DIR" \ + && docker compose -f "$compose_file" build "${build_args[@]}" lnbits \ + && docker compose -f "$compose_file" up -d --force-recreate lnbits) +} + +regtest-lnbits-restart() { + _devenv_load_config + local compose_file + compose_file="$(_regtest_lnbits_compose_file)" + [[ -f "$compose_file" ]] || { echo "Compose file not found: $compose_file"; return 1; } + docker compose -f "$compose_file" restart lnbits +} diff --git a/modules/dev-env/scripts/status.sh b/modules/dev-env/scripts/status.sh new file mode 100644 index 0000000..87508a5 --- /dev/null +++ b/modules/dev-env/scripts/status.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# dev-status: show divergence and dirty state of every dev-env worktree +# +# Replaces ~/omarchy-dev-env/setup/dev-status.sh, which referenced a +# stale $PROJECTS_DIR layout. This version reads /etc/dev-env/projects.json +# and walks the actual declared worktrees. + +set -euo pipefail + +if [[ -r /etc/dev-env/config.sh ]]; then + # shellcheck disable=SC1091 + source /etc/dev-env/config.sh +fi + +PROJECTS_JSON="${DEVENV_PROJECTS_JSON:-/etc/dev-env/projects.json}" + +if [[ ! -r "$PROJECTS_JSON" ]]; then + echo "projects.json not found at $PROJECTS_JSON" >&2 + exit 1 +fi + +if ! command -v jq >/dev/null; then + echo "jq required" >&2 + exit 1 +fi + +# Colors — shared palette (RED/GREEN/YELLOW/BLUE/CYAN/BOLD/NC …) +if [[ -r /etc/dev-env/lib-colors.sh ]]; then + # shellcheck disable=SC1091 + source /etc/dev-env/lib-colors.sh +fi + +issues=() + +header() { + echo "" + echo -e "${BOLD}${CYAN}═══════════════════════════════════════════════════════${NC}" + echo -e "${BOLD}${CYAN} $1${NC}" + echo -e "${BOLD}${CYAN}═══════════════════════════════════════════════════════${NC}" +} + +check_one() { + local label="$1" path="$2" has_upstream="${3:-false}" + + if [[ ! -d "$path/.git" ]] && [[ ! -f "$path/.git" ]]; then + printf " ${YELLOW}⚠${NC} %s: not present\n" "$label" + issues+=("$label: not present") + return + fi + + git -C "$path" fetch --all --quiet 2>/dev/null || true + + local branch icons="" status_str="" + branch="$(git -C "$path" branch --show-current 2>/dev/null || echo '?')" + + [[ -n "$(git -C "$path" status --porcelain)" ]] && { + icons+="${YELLOW}●${NC} " + status_str+="dirty " + } + + local behind ahead + behind=$(git -C "$path" rev-list --count "HEAD..origin/$branch" 2>/dev/null || echo 0) + ahead=$(git -C "$path" rev-list --count "origin/$branch..HEAD" 2>/dev/null || echo 0) + (( behind > 0 )) && icons+="${RED}↓${NC}$behind " + (( ahead > 0 )) && icons+="${GREEN}↑${NC}$ahead " + + if [[ "$has_upstream" == "true" ]] && git -C "$path" remote get-url upstream &>/dev/null; then + local ub="main" + git -C "$path" rev-parse upstream/main &>/dev/null || ub="master" + local ub_behind + ub_behind=$(git -C "$path" rev-list --count "HEAD..upstream/$ub" 2>/dev/null || echo 0) + if (( ub_behind > 0 )); then + icons+="${CYAN}⇣${NC}$ub_behind " + issues+=("$label: $ub_behind behind upstream/$ub") + fi + fi + + [[ -z "$icons" ]] && icons="${GREEN}✓${NC}" + printf " %-45s %s (%s)\n" "$label" "$icons" "$branch" +} + +header "Development Environment Status" +echo -e " ${BLUE}Date:${NC} $(date '+%Y-%m-%d %H:%M')" +echo -e " ${BLUE}Host:${NC} $(hostname)" +echo -e " ${BLUE}Root:${NC} ${DEV_ROOT:-?}" + +# Walk every project and worktree from the JSON. +mapfile -t PROJECTS < <(jq -r 'keys[]' "$PROJECTS_JSON") + +for proj in "${PROJECTS[@]}"; do + echo "" + echo -e "${BLUE}─── $proj ───${NC}" + + is_clone="$(jq -r --arg p "$proj" '.[$p].isClone' "$PROJECTS_JSON")" + has_upstream_decl="$(jq -r --arg p "$proj" '.[$p].remotes.upstream != null' "$PROJECTS_JSON")" + + if [[ "$is_clone" == "true" ]]; then + clone_path="$(jq -r --arg p "$proj" '.[$p].clonePath' "$PROJECTS_JSON")" + check_one "$proj" "$clone_path" "$has_upstream_decl" + else + while IFS=$'\t' read -r wt_name wt_path; do + [[ -z "$wt_name" || "$wt_path" == "null" ]] && continue + check_one "$proj/$wt_name" "$wt_path" "$has_upstream_decl" + done < <(jq -r --arg p "$proj" ' + .[$p].worktrees + | to_entries[] + | "\(.key)\t\(.value.path)" + ' "$PROJECTS_JSON") + fi +done + +# Docker +echo "" +echo -e "${BLUE}─── Docker ───${NC}" +if command -v docker &>/dev/null; then + running="$(docker ps --format '{{.Names}}' 2>/dev/null | wc -l)" + echo -e " ${BLUE}Containers running:${NC} $running" +fi + +header "Summary" +if (( ${#issues[@]} == 0 )); then + echo -e " ${GREEN}✓ All worktrees are clean and in sync!${NC}" +else + echo -e " ${YELLOW}Issues found:${NC}" + for issue in "${issues[@]}"; do + echo -e " ${YELLOW}•${NC} $issue" + done +fi + +cat < fetch upstream + show divergence + rebase status which forks need rebasing onto upstream + dev-env-bootstrap materialize missing worktrees + +EOF diff --git a/modules/dev-env/scripts/tmux-launch.sh b/modules/dev-env/scripts/tmux-launch.sh new file mode 100644 index 0000000..031b468 --- /dev/null +++ b/modules/dev-env/scripts/tmux-launch.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# dev-tm: launch a declared tmux session +# +# Reads /etc/dev-env/tmux-sessions.json (rendered by config.nix) and +# either attaches to an existing session or creates one with the +# declared windows. Window cwds are resolved relative to $DEV_ROOT. +# +# This is a single generic launcher; the window layouts come from Nix +# (or a runtime override at ~/.config/dev-env/tmux-sessions.json). + +set -euo pipefail + +if [[ -r /etc/dev-env/config.sh ]]; then + # shellcheck disable=SC1091 + source /etc/dev-env/config.sh +fi + +DEV_ROOT="${DEV_ROOT:-$HOME/dev}" + +# Prefer a user override if present +SESSIONS_JSON="" +if [[ -r "$HOME/.config/dev-env/tmux-sessions.json" ]]; then + SESSIONS_JSON="$HOME/.config/dev-env/tmux-sessions.json" +elif [[ -r /etc/dev-env/tmux-sessions.json ]]; then + SESSIONS_JSON=/etc/dev-env/tmux-sessions.json +else + echo "No tmux-sessions.json found" >&2 + exit 1 +fi + +usage() { + cat < start or attach to + dev-tm -k kill session + dev-tm -k all kill all dev sessions + dev-tm -l list available sessions +EOF +} + +list_sessions() { + echo "Defined sessions (from $SESSIONS_JSON):" + jq -r 'keys[]' "$SESSIONS_JSON" | sed 's/^/ /' + echo "" + echo "Active tmux sessions:" + tmux list-sessions 2>/dev/null | sed 's/^/ /' || echo " (none)" +} + +resolve_path() { + local p="$1" + [[ -z "$p" || "$p" == "null" ]] && { echo "$DEV_ROOT"; return; } + [[ "$p" = /* ]] && { echo "$p"; return; } + echo "$DEV_ROOT/$p" +} + +start_session() { + local name="$1" + local session_name="dev-$name" + + if tmux has-session -t "$session_name" 2>/dev/null; then + tmux attach-session -t "$session_name" + return + fi + + local session_cwd + session_cwd="$(resolve_path "$(jq -r --arg s "$name" '.[$s].cwd // empty' "$SESSIONS_JSON")")" + + local nwindows + nwindows="$(jq --arg s "$name" '.[$s].windows | length' "$SESSIONS_JSON")" + if (( nwindows == 0 )); then + echo "Session '$name' has no windows defined" >&2 + exit 1 + fi + + # First window + local first_name first_cwd first_cmd + first_name="$(jq -r --arg s "$name" '.[$s].windows[0].name' "$SESSIONS_JSON")" + first_cwd="$(resolve_path "$(jq -r --arg s "$name" '.[$s].windows[0].cwd // empty' "$SESSIONS_JSON")")" + first_cmd="$(jq -r --arg s "$name" '.[$s].windows[0].cmd // empty' "$SESSIONS_JSON")" + + tmux new-session -d -s "$session_name" -n "$first_name" -c "$first_cwd" + [[ -n "$first_cmd" && "$first_cmd" != "null" ]] && \ + tmux send-keys -t "$session_name:0" "$first_cmd" C-m + + # Remaining windows + for ((i = 1; i < nwindows; i++)); do + local wn wcwd wcmd + wn="$(jq -r --arg s "$name" --argjson i "$i" '.[$s].windows[$i].name' "$SESSIONS_JSON")" + wcwd="$(resolve_path "$(jq -r --arg s "$name" --argjson i "$i" '.[$s].windows[$i].cwd // empty' "$SESSIONS_JSON")")" + wcmd="$(jq -r --arg s "$name" --argjson i "$i" '.[$s].windows[$i].cmd // empty' "$SESSIONS_JSON")" + tmux new-window -t "$session_name" -n "$wn" -c "$wcwd" + [[ -n "$wcmd" && "$wcmd" != "null" ]] && \ + tmux send-keys -t "$session_name:$i" "$wcmd" C-m + done + + tmux select-window -t "$session_name:0" + tmux attach-session -t "$session_name" +} + +kill_session() { + local target="$1" + if [[ "$target" == "all" ]]; then + tmux list-sessions -F '#{session_name}' 2>/dev/null \ + | grep '^dev-' \ + | xargs -r -n1 tmux kill-session -t \ + || echo "No dev sessions to kill" + return + fi + tmux kill-session -t "dev-$target" 2>/dev/null || echo "Session 'dev-$target' not found" +} + +# --- entry --- +case "${1:-}" in + -h|--help) usage ;; + -l|--list|"") list_sessions ;; + -k|--kill) + [[ -z "${2:-}" ]] && { echo "Usage: dev-tm -k "; exit 1; } + kill_session "$2" + ;; + *) + if ! jq -e --arg s "$1" 'has($s)' "$SESSIONS_JSON" >/dev/null; then + echo "Unknown session: $1" >&2 + list_sessions + exit 1 + fi + start_session "$1" + ;; +esac diff --git a/modules/dev-env/scripts/worktree.sh b/modules/dev-env/scripts/worktree.sh new file mode 100644 index 0000000..de43a1b --- /dev/null +++ b/modules/dev-env/scripts/worktree.sh @@ -0,0 +1,312 @@ +#!/usr/bin/env bash +# dev-env: git worktree helpers +# +# Provides: +# - wt / wts / wtu generic worktree listing/sync/upstream-fetch +# - wtn / worktree-spawn create a new branch + worktree from any project +# - bw-spawn spawn a boilerplate-website experiment + install +# - lnbits-status show dev/main divergence vs upstream +# - lnbits-sync-dev merge upstream/dev into dev worktree +# - lnbits-sync-main merge upstream/main into main worktree +# +# Ported from ~/dev/.devenv.d/80-worktrees.sh with the bare-repo path +# now coming from /etc/dev-env/config.sh. + +# Shared config loader (_devenv_load_config) lives in /etc/dev-env/lib.sh. +if [[ -r /etc/dev-env/lib.sh ]]; then + # shellcheck disable=SC1091 + source /etc/dev-env/lib.sh +fi + +#------------------------------------------------------------------------------- +# Generic worktree helpers +#------------------------------------------------------------------------------- + +worktree-list() { + _devenv_load_config + local repo_name="${1:-}" + local repos_dir="${REPOS_DIR:-$DEV_ROOT/repos}" + + if [[ -n "$repo_name" ]]; then + local bare_repo="$repos_dir/${repo_name}.git" + if [[ -d "$bare_repo" ]]; then + git -C "$bare_repo" worktree list + else + echo "Repo not found: $repo_name" + return 1 + fi + else + echo "=== All Worktrees ===" + for repo in "$repos_dir"/*.git; do + [[ -d "$repo" ]] || continue + echo "" + echo "--- $(basename "$repo" .git) ---" + git -C "$repo" worktree list + done + fi +} + +worktree-sync() { + _devenv_load_config + local repos_dir="${REPOS_DIR:-$DEV_ROOT/repos}" + echo "=== Syncing Worktrees ===" + for repo in "$repos_dir"/*.git; do + [[ -d "$repo" ]] || continue + local name + name="$(basename "$repo" .git)" + echo "" + echo "--- $name ---" + git -C "$repo" fetch --all --quiet 2>/dev/null || true + git -C "$repo" worktree list | while read -r line; do + # format: [] + local path branch status + path="$(awk '{print $1}' <<<"$line")" + branch="$(awk '{print $3}' <<<"$line" | tr -d '[]')" + if [[ -d "$path" ]]; then + status="$(git -C "$path" status -sb 2>/dev/null | head -1)" + printf " %-20s %s\n" "${branch:-?}" "$status" + fi + done + done +} + +worktree-update-upstream() { + _devenv_load_config + local repo_name="${1:-}" + local repos_dir="${REPOS_DIR:-$DEV_ROOT/repos}" + + if [[ -z "$repo_name" ]]; then + echo "Usage: wtu " + return 1 + fi + + local bare_repo="$repos_dir/${repo_name}.git" + if [[ ! -d "$bare_repo" ]]; then + echo "Repo not found: $bare_repo" + return 1 + fi + + if ! git -C "$bare_repo" remote get-url upstream &>/dev/null; then + echo "No upstream remote configured for $repo_name" + return 1 + fi + + echo "Fetching upstream..." + git -C "$bare_repo" fetch upstream + + echo "" + echo "=== Upstream Status: $repo_name ===" + + # Determine upstream base branch (main or master). + local base_branch="main" + git -C "$bare_repo" show-ref --verify --quiet refs/remotes/upstream/main || base_branch="master" + + # Show each local branch's divergence vs the upstream base. + git -C "$bare_repo" for-each-ref --format='%(refname:short)' refs/heads | while read -r br; do + local behind ahead + behind=$(git -C "$bare_repo" rev-list --count "$br..upstream/$base_branch" 2>/dev/null || echo ?) + ahead=$(git -C "$bare_repo" rev-list --count "upstream/$base_branch..$br" 2>/dev/null || echo ?) + printf " %-24s behind:%s ahead:%s\n" "$br" "$behind" "$ahead" + done +} + +#------------------------------------------------------------------------------- +# Spawn a new worktree (== branch) from an existing project. +#------------------------------------------------------------------------------- +# Usage: worktree-spawn [base-branch] +# +# Locates the bare repo, creates from (default +# main, else master), and adds a worktree at the project root next to the +# base worktree. Suited to "spin off an experiment from the boilerplate" +# flows — e.g. `worktree-spawn boilerplate-website interior-designer`. + +worktree-spawn() { + _devenv_load_config + local repo_name="${1:-}" + local new_branch="${2:-}" + local base_branch="${3:-}" + + if [[ -z "$repo_name" || -z "$new_branch" ]]; then + echo "Usage: worktree-spawn [base-branch]" + return 1 + fi + + local repos_dir="${REPOS_DIR:-$DEV_ROOT/repos}" + local bare_repo="$repos_dir/${repo_name}.git" + + [[ -d "$bare_repo" ]] || { echo "Repo not found: $bare_repo"; return 1; } + + if [[ -z "$base_branch" ]]; then + if git -C "$bare_repo" show-ref --verify --quiet refs/heads/main; then + base_branch="main" + elif git -C "$bare_repo" show-ref --verify --quiet refs/heads/master; then + base_branch="master" + else + echo "No main/master in $repo_name; pass base branch explicitly." + return 1 + fi + fi + + local base_path + base_path="$(git -C "$bare_repo" worktree list --porcelain | awk -v t="refs/heads/$base_branch" ' + /^worktree / { wt = $2 } + /^branch / && $2 == t { print wt; exit } + ')" + [[ -n "$base_path" ]] || { echo "No worktree for $repo_name@$base_branch"; return 1; } + + local project_root new_path + project_root="$(dirname "$base_path")" + new_path="$project_root/$new_branch" + + [[ -e "$new_path" ]] && { echo "Path already exists: $new_path"; return 1; } + + echo "Creating branch '$new_branch' from '$base_branch' in $repo_name..." + git -C "$bare_repo" branch "$new_branch" "$base_branch" || return 1 + + echo "Adding worktree at $new_path..." + git -C "$bare_repo" worktree add "$new_path" "$new_branch" || return 1 + + echo "" + echo "Ready: $new_path" + [[ -f "$new_path/package.json" ]] && echo "Next: cd $new_path && pnpm install" +} + +#------------------------------------------------------------------------------- +# Spawn a boilerplate-website experiment AND install deps. +#------------------------------------------------------------------------------- +# Usage: bw-spawn +# +# Wraps worktree-spawn with the boilerplate-website repo pre-filled and +# runs `pnpm install` in the new worktree. The site is ready to `pnpm dev` +# the moment this returns. + +bw-spawn() { + local name="${1:-}" + if [[ -z "$name" ]]; then + echo "Usage: bw-spawn " + return 1 + fi + + worktree-spawn boilerplate-website "$name" main || return $? + + _devenv_load_config + local wt_path="${DEV_ROOT:-$HOME/dev}/boilerplate-website/$name" + if [[ -f "$wt_path/package.json" ]]; then + echo "" + echo "Installing deps..." + ( cd "$wt_path" && pnpm install ) || return $? + fi + + echo "" + echo "Done. cd $wt_path && pnpm dev" +} + +#------------------------------------------------------------------------------- +# lnbits-specific workflow helpers +#------------------------------------------------------------------------------- +# Mental model: +# Our `dev` and `main` are aiolabs branches that diverge from upstream +# and carry aiolabs-only commits on top. The sync helpers below +# `merge upstream/` to vendor upstream changes — they don't +# fast-forward. +# +# `main` feeds every host (deploy flake's `lnbits` input is ?ref=main). +# The former `demo` branch is retired — what used to be demo-only +# behavior now lives on main behind per-host overrides on the +# server-deploy flake's `lnbits` input. `dev` is the in-progress +# staging branch; we cut releases by merging dev → main. +# +# `lnbits-status` reports divergence vs upstream so we know how stale +# the vendor base is. + +_lnbits_paths() { + _devenv_load_config + BARE="${REPOS_DIR:-$DEV_ROOT/repos}/lnbits.git" + LNBITS_ROOT="${LNBITS_DIR:-$DEV_ROOT/lnbits}" +} + +lnbits-status() { + _lnbits_paths + if [[ ! -d "$BARE" ]]; then + echo "lnbits bare repo not found: $BARE" + return 1 + fi + + git -C "$BARE" fetch upstream --quiet 2>/dev/null || true + git -C "$BARE" fetch origin --quiet 2>/dev/null || true + + echo "=== lnbits Branch Status ===" + echo "" + echo "Branch layout (our branches diverge from upstream; sync via merge):" + echo " dev = aiolabs staging (vendored from upstream/dev)" + echo " main = aiolabs stable (vendored from upstream/main)" + echo "" + + for env in dev main; do + local wt="$LNBITS_ROOT/$env" + if [[ ! -d "$wt" ]]; then + printf " %-6s: (worktree not created)\n" "$env" + continue + fi + local branch behind_u ahead_u + branch="$(git -C "$wt" branch --show-current)" + case "$env" in + dev) + behind_u=$(git -C "$BARE" rev-list --count "$branch..upstream/dev" 2>/dev/null || echo ?) + ahead_u=$(git -C "$BARE" rev-list --count "upstream/dev..$branch" 2>/dev/null || echo ?) + printf " dev: %-3s behind upstream/dev %-3s ahead\n" "$behind_u" "$ahead_u" + ;; + main) + behind_u=$(git -C "$BARE" rev-list --count "$branch..upstream/main" 2>/dev/null || echo ?) + ahead_u=$(git -C "$BARE" rev-list --count "upstream/main..$branch" 2>/dev/null || echo ?) + printf " main: %-3s behind upstream/main %-3s ahead\n" "$behind_u" "$ahead_u" + ;; + esac + done + + echo "" + echo "Commands:" + echo " lnbits-sync-dev # merge upstream/dev into dev" + echo " lnbits-sync-main # merge upstream/main into main worktree" +} + +lnbits-sync-dev() { + _lnbits_paths + local dev_dir="$LNBITS_ROOT/dev" + [[ -d "$BARE" ]] || { echo "bare repo missing: $BARE"; return 1; } + [[ -d "$dev_dir" ]] || { echo "dev worktree missing: $dev_dir"; return 1; } + + echo "Fetching upstream..." + git -C "$BARE" fetch upstream + + echo "Merging upstream/dev into dev..." + git -C "$dev_dir" merge upstream/dev + + echo "" + echo "Dev synced. Review, then: lb dev && git push" +} + +lnbits-sync-main() { + _lnbits_paths + local main_dir="$LNBITS_ROOT/main" + [[ -d "$BARE" ]] || { echo "bare repo missing: $BARE"; return 1; } + [[ -d "$main_dir" ]] || { echo "main worktree missing: $main_dir"; return 1; } + + echo "Fetching upstream..." + git -C "$BARE" fetch upstream + + echo "Merging upstream/main into main worktree..." + git -C "$main_dir" merge upstream/main + + echo "" + echo "Main synced. Review, then: lb main && git push" +} + +# Aliases keep the original two-letter shortcuts. +alias wt='worktree-list' +alias wts='worktree-sync' +alias wtu='worktree-update-upstream' +alias wtn='worktree-spawn' +alias lbs='lnbits-status' +alias lbsd='lnbits-sync-dev' +alias lbsm='lnbits-sync-main' diff --git a/modules/dev-env/tests/smoke.nix b/modules/dev-env/tests/smoke.nix new file mode 100644 index 0000000..810cb59 --- /dev/null +++ b/modules/dev-env/tests/smoke.nix @@ -0,0 +1,173 @@ +# Standalone smoke test for the dev-env module. +# +# Builds a minimal nixosConfiguration that imports only the dev-env +# module (plus a minimum-bootable stub) and exercises a representative +# slice of the option schema: +# +# - worktree-based project with upstream + github fork (3 remotes) +# - worktree-based project without upstream (origin only) +# - isClone project with upstream +# - minimal project (mkProject defaults) +# - tmux session schema +# - deploy targets +# +# Consumed by the top-level flake.nix as `checks.${system}.dev-env-*` +# so `nix flake check` catches regressions without loading the full +# omni system. The three checks render small files +# (projects.json, config.sh, tmux-sessions.json) which is cheap to +# build even without a binary cache. +# +# Run directly: +# nix flake check +# nix build .#checks.x86_64-linux.dev-env-projects-json +# cat $(nix build --no-link --print-out-paths .#checks.x86_64-linux.dev-env-projects-json) +# +# This is NOT bootable as a real system — the fileSystem/bootloader +# stubs only exist to satisfy module evaluation. Do not try to deploy. +{ nixpkgs, home-manager }: + +nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + home-manager.nixosModules.home-manager + + # The dev-env module itself. + ../default.nix + + # No omni stub needed: dev-env is self-contained (uses its own + # `dev-env.user` and standard `virtualisation.docker.enable`, with no + # `config.omni.*` references). This standalone build IS the proof of + # that decoupling. + + # Minimal host stub + dev-env exercise. + ( + { + config, + lib, + pkgs, + ... + }: + { + # ---- Minimum bootable stubs (never actually booted) ---- + + fileSystems."/" = { + device = "none"; + fsType = "tmpfs"; + }; + boot.loader.systemd-boot.enable = true; + boot.loader.efi.canTouchEfiVariables = true; + nixpkgs.hostPlatform = "x86_64-linux"; + system.stateVersion = "25.11"; + + # home-manager wants these set even with zero users. + home-manager.useGlobalPkgs = true; + home-manager.useUserPackages = true; + + # ---- Exercise the dev-env schema ---- + + dev-env = { + enable = true; + + # Explicit root — don't depend on config.dev-env.user defaulting. + root = "/tmp/dev-env-smoke"; + + forgejo = { + host = "git.example.com"; + org = "test-org"; + }; + + github.forkUser = "testuser"; + + deploy = { + flakeInput = "deploy-flake"; + targets = { + host-a = "root@10.0.0.1"; + host-b = "root@10.0.0.2"; + }; + deriveProjectsFromInputs = false; + }; + + # Disable regtest to avoid dragging docker into the eval. + regtest.enable = false; + + tmux = { + enable = true; + sessions.dev = { + cwd = "lnbits"; + windows = [ + { + name = "dev"; + cwd = "lnbits/dev"; + cmd = "nvim ."; + } + { + name = "term"; + cwd = "lnbits/dev"; + cmd = null; + } + { + name = "git"; + cwd = "lnbits/dev"; + cmd = "lazygit"; + } + ]; + }; + }; + + # Use mkProject so we exercise the lib helper in addition to + # the raw submodule schema. + projects = + let + mk = config.dev-env.lib.mkProject; + in + { + # Worktree-based with upstream + github-fork (3 remotes). + worktree-with-upstream = mk { + name = "worktree-with-upstream"; + category = "shared"; + upstream = "https://github.com/upstream-org/worktree-with-upstream"; + worktrees = { + main = { + branch = "main"; + }; + dev = { + branch = "dev"; + }; + feature = { + branch = "feature/x"; + }; + }; + }; + + # Worktree-based, no upstream (origin only). + worktree-no-upstream = mk { + name = "worktree-no-upstream"; + category = "shared"; + worktrees = { + alpha = { + branch = "alpha"; + }; + beta = { + branch = "beta"; + }; + }; + }; + + # Single-clone with upstream (rebase helper target). + clone-with-upstream = mk { + name = "clone-with-upstream"; + category = "test"; + upstream = "https://github.com/upstream-org/clone-with-upstream"; + isClone = true; + }; + + # Minimal project — mkProject defaults fill everything. + minimal = mk { + name = "minimal"; + }; + }; + }; + } + ) + ]; +}