feat(dev-env): shell scripts (git-hooks, worktree, deploy, regtest, nav) and smoke test
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7960f82494
commit
dbd9e76027
13 changed files with 2496 additions and 0 deletions
293
modules/dev-env/scripts/bootstrap.sh
Normal file
293
modules/dev-env/scripts/bootstrap.sh
Normal file
|
|
@ -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}/<basename>.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 <<EOF
|
||||||
|
dev-env-bootstrap — materialize bare repos + worktrees from /etc/dev-env/projects.json
|
||||||
|
|
||||||
|
USAGE:
|
||||||
|
dev-env-bootstrap [OPTIONS] [project-name]
|
||||||
|
|
||||||
|
OPTIONS:
|
||||||
|
-n, --dry-run show what would happen, do not change anything
|
||||||
|
-v, --verbose print every git command
|
||||||
|
-f, --force-remotes silently rewrite remote URLs that differ from spec
|
||||||
|
-h, --help show this help
|
||||||
|
|
||||||
|
EXAMPLES:
|
||||||
|
dev-env-bootstrap --dry-run # full preview
|
||||||
|
dev-env-bootstrap # bring everything up to spec
|
||||||
|
dev-env-bootstrap lnbits # only operate on the lnbits project
|
||||||
|
|
||||||
|
Reads from: $PROJECTS_JSON
|
||||||
|
Writes to: $DEV_ROOT
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-n|--dry-run) DRY_RUN=true ;;
|
||||||
|
-v|--verbose) VERBOSE=true ;;
|
||||||
|
-f|--force-remotes) FORCE_REMOTES=true ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
-*) echo "unknown option: $1"; usage; exit 1 ;;
|
||||||
|
*) ONLY_PROJECT="$1" ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Output -----------------------------------------------------------
|
||||||
|
|
||||||
|
# Shared ANSI palette (RED/GREEN/YELLOW/BLUE/DIM/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}[..]${NC} $*"; }
|
||||||
|
ok() { echo -e "${GREEN}[ok]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[!!]${NC} $*"; }
|
||||||
|
err() { echo -e "${RED}[XX]${NC} $*" >&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-name<TAB>url" 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 "wtname<TAB>branch<TAB>path<TAB>remote" 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
|
||||||
215
modules/dev-env/scripts/deploy.sh
Normal file
215
modules/dev-env/scripts/deploy.sh
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# dev-deploy: thin wrapper around the unified deploy flake
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# dev-deploy <host> switch on host (uses locked deploy flake input)
|
||||||
|
# dev-deploy <host> test test build (no switch)
|
||||||
|
# dev-deploy <host> build local build only
|
||||||
|
# dev-deploy --local <host> 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 <<EOF
|
||||||
|
dev-deploy — wraps nixos-rebuild against the unified deploy flake
|
||||||
|
|
||||||
|
USAGE:
|
||||||
|
dev-deploy <host> deploy (switch)
|
||||||
|
dev-deploy <host> test test build (no switch)
|
||||||
|
dev-deploy <host> build local build only (no target)
|
||||||
|
dev-deploy --local <host> deploy with --override-input from local worktrees
|
||||||
|
dev-deploy --local <host> 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_<HOST>=<ssh> (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 <input> path:<worktree-path>
|
||||||
|
# Picks the first worktree by default; pass DEVENV_OVERRIDE_WORKTREE=<name>
|
||||||
|
# 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
|
||||||
138
modules/dev-env/scripts/git-hooks/pre-commit
Normal file
138
modules/dev-env/scripts/git-hooks/pre-commit
Normal file
|
|
@ -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 "<marker> start" ... "<marker> 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 "<marker> 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
|
||||||
17
modules/dev-env/scripts/lib-colors.sh
Normal file
17
modules/dev-env/scripts/lib-colors.sh
Normal file
|
|
@ -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'
|
||||||
24
modules/dev-env/scripts/lib.sh
Normal file
24
modules/dev-env/scripts/lib.sh
Normal file
|
|
@ -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; }
|
||||||
203
modules/dev-env/scripts/nav.sh
Normal file
203
modules/dev-env/scripts/nav.sh
Normal file
|
|
@ -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 <worktree>"
|
||||||
|
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 <target>"
|
||||||
|
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 <host>"
|
||||||
|
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,<host>}"
|
||||||
|
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
|
||||||
|
}
|
||||||
154
modules/dev-env/scripts/pr-helpers.sh
Normal file
154
modules/dev-env/scripts/pr-helpers.sh
Normal file
|
|
@ -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}/<repo>-<branch> 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}/<repo>-<branch>.
|
||||||
|
#
|
||||||
|
# Usage: git-pr-branch <repo-name> <branch-name>
|
||||||
|
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 <repo-name> <branch-name>"
|
||||||
|
echo "Example: git-pr-branch lnbits fix-invoice-bug"
|
||||||
|
echo ""
|
||||||
|
echo "Creates a worktree at $prs_dir/<repo>-<branch>"
|
||||||
|
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:-<user>}/${repo_name}.git"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Ready! Your PR worktree is at:
|
||||||
|
cd $pr_path
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
1. cd $pr_path
|
||||||
|
2. Edit, test, commit
|
||||||
|
3. git push github-fork $branch_name
|
||||||
|
4. Open the PR on GitHub (against upstream/$base_branch)
|
||||||
|
5. After merge: git-pr-cleanup $repo_name $branch_name
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove a PR worktree after the PR is merged (or abandoned).
|
||||||
|
git-pr-cleanup() {
|
||||||
|
_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-cleanup <repo-name> <branch-name>"
|
||||||
|
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'
|
||||||
410
modules/dev-env/scripts/rebase.sh
Normal file
410
modules/dev-env/scripts/rebase.sh
Normal file
|
|
@ -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 <<EOF
|
||||||
|
|
||||||
|
${BOLD}${YELLOW}Conflict Resolution Guide:${NC}
|
||||||
|
1. Check conflicts: ${CYAN}git status${NC}
|
||||||
|
2. Resolve markers <<<<<<< ======= >>>>>>>
|
||||||
|
3. Stage: ${CYAN}git add <file>${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 "<path>:<display-name>" 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 <<EOF
|
||||||
|
${BOLD}rebase${NC} — Safely rebase forks onto upstream
|
||||||
|
|
||||||
|
USAGE:
|
||||||
|
rebase Interactive mode
|
||||||
|
rebase single <path> 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 <name> 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 "$@"
|
||||||
284
modules/dev-env/scripts/regtest.sh
Normal file
284
modules/dev-env/scripts/regtest.sh
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# dev-env: Bitcoin/Lightning regtest docker environment
|
||||||
|
#
|
||||||
|
# Provides:
|
||||||
|
# regtest-start [env|pr:<branch>] [--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:<branch>] [--path <dir>] [--seed|--keep]
|
||||||
|
#
|
||||||
|
# env dev|main (worktree under ~/dev/lnbits/<env>)
|
||||||
|
# pr:<branch> ~/dev/upstream-prs/lnbits-<branch>
|
||||||
|
# --path <dir> 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 <<USAGE
|
||||||
|
Unknown argument: $1
|
||||||
|
Usage: regtest-start [dev|main|pr:<branch>] [--path <dir>] [--seed|--keep]
|
||||||
|
|
||||||
|
Environments:
|
||||||
|
dev, main ~/dev/lnbits/<env>
|
||||||
|
pr:<branch> ~/dev/upstream-prs/lnbits-<branch>
|
||||||
|
--path <dir> 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 <<EOF
|
||||||
|
|
||||||
|
Regtest running:
|
||||||
|
LNbits: http://localhost:5001/
|
||||||
|
Mempool: http://localhost:8080/
|
||||||
|
Boltz: http://localhost:9001/
|
||||||
|
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Commands: regtest-stop, regtest-logs, regtest-cli, regtest-status"
|
||||||
|
}
|
||||||
|
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
# Stop
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
regtest-stop() {
|
||||||
|
_devenv_load_config
|
||||||
|
|
||||||
|
if [[ ! -d "$REGTEST_DIR" ]]; then
|
||||||
|
echo "Regtest environment not found"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Stopping regtest environment..."
|
||||||
|
(cd "$REGTEST_DIR"
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source ./docker-scripts.sh
|
||||||
|
docker compose down -v)
|
||||||
|
echo "Regtest stopped"
|
||||||
|
}
|
||||||
|
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
# Status / logs / CLI
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
regtest-status() {
|
||||||
|
_devenv_load_config
|
||||||
|
|
||||||
|
echo "=== Shared Regtest Environment ==="
|
||||||
|
if _regtest_is_running; then
|
||||||
|
echo "Status: RUNNING"
|
||||||
|
echo ""
|
||||||
|
docker ps --filter "name=lnbits-" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | head -15
|
||||||
|
|
||||||
|
local balance
|
||||||
|
balance="$(docker exec lnbits-lnd-4-1 lncli --network=regtest walletbalance 2>/dev/null \
|
||||||
|
| grep -oP '"total_balance":\s*"\K[0-9]+' || echo 0)"
|
||||||
|
echo ""
|
||||||
|
echo "lnd-4 balance: $balance sats"
|
||||||
|
else
|
||||||
|
echo "Status: STOPPED"
|
||||||
|
echo "Start with: regtest-start"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
regtest-logs() {
|
||||||
|
_devenv_load_config
|
||||||
|
local service="${1:-}"
|
||||||
|
[[ -d "$REGTEST_DIR" ]] || { echo "Regtest not found"; return 1; }
|
||||||
|
if [[ -n "$service" ]]; then
|
||||||
|
(cd "$REGTEST_DIR" && docker compose logs -f "$service")
|
||||||
|
else
|
||||||
|
(cd "$REGTEST_DIR" && docker compose logs -f)
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
regtest-cli() {
|
||||||
|
_devenv_load_config
|
||||||
|
[[ -f "$REGTEST_DIR/docker-scripts.sh" ]] || { echo "docker-scripts.sh missing"; return 1; }
|
||||||
|
echo "Sourcing regtest CLI helpers..."
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$REGTEST_DIR/docker-scripts.sh"
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Available commands:
|
||||||
|
bitcoin-cli-sim Bitcoin Core CLI
|
||||||
|
lncli-sim <1-4> 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
|
||||||
|
}
|
||||||
142
modules/dev-env/scripts/status.sh
Normal file
142
modules/dev-env/scripts/status.sh
Normal file
|
|
@ -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 <<EOF
|
||||||
|
|
||||||
|
${BLUE}Legend:${NC}
|
||||||
|
${GREEN}✓${NC} clean ${YELLOW}●${NC} dirty ${GREEN}↑${NC} ahead origin
|
||||||
|
${RED}↓${NC} behind origin ${CYAN}⇣${NC} behind upstream
|
||||||
|
|
||||||
|
${BLUE}Quick actions:${NC}
|
||||||
|
wts sync all worktrees with origin
|
||||||
|
wtu <repo> fetch upstream + show divergence
|
||||||
|
rebase status which forks need rebasing onto upstream
|
||||||
|
dev-env-bootstrap materialize missing worktrees
|
||||||
|
|
||||||
|
EOF
|
||||||
131
modules/dev-env/scripts/tmux-launch.sh
Normal file
131
modules/dev-env/scripts/tmux-launch.sh
Normal file
|
|
@ -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 <<EOF
|
||||||
|
dev-tm — declarative tmux session launcher
|
||||||
|
|
||||||
|
USAGE:
|
||||||
|
dev-tm list available sessions
|
||||||
|
dev-tm <session> start or attach to <session>
|
||||||
|
dev-tm -k <session> 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 <session|all>"; 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
|
||||||
312
modules/dev-env/scripts/worktree.sh
Normal file
312
modules/dev-env/scripts/worktree.sh
Normal file
|
|
@ -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: <path> <sha> [<branch>]
|
||||||
|
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 <repo-name>"
|
||||||
|
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 <repo> <new-branch> [base-branch]
|
||||||
|
#
|
||||||
|
# Locates the bare repo, creates <new-branch> from <base-branch> (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 <repo-name> <new-branch-name> [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 <experiment-name>
|
||||||
|
#
|
||||||
|
# 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 <experiment-name>"
|
||||||
|
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/<branch>` 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'
|
||||||
173
modules/dev-env/tests/smoke.nix
Normal file
173
modules/dev-env/tests/smoke.nix
Normal file
|
|
@ -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";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue