This repository has been archived on 2026-06-22. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
lnbits-sensei/modules/dev-env/scripts/bootstrap.sh
Padreug e38d313db2 feat(dev-env): backport matured dev-env implementation from /etc/nixos
Replace the stub dev-env with the real, working implementation that grew
in the reference machine config — de-identified for the public scaffold.

Nix layer:
- options.nix: full project schema (url/upstream/fork/category/
  worktreeRoot/worktrees{branch,path,remote}/isClone/deployFlakeInput),
  deploy.targets, github.forkUser, writeDirenvHints. Drops the
  forgejo-URL block + deploy-flake auto-derivation (incoherent in a
  scaffold that uses explicit per-project urls).
- lib.nix: mkProject + worktreePath/bareRepoPath/projectRemotes,
  generalized to the explicit-url model (origin falls back to upstream).
- config.nix: renders /etc/dev-env/{config.sh,projects.json,
  tmux-sessions.json}, installs helpers via writeShellScriptBin, loads
  shell functions into interactive shells, wires the git pre-commit hook.

Scripts (config-driven, read /etc/dev-env at runtime):
- bootstrap.sh, nav.sh, worktree.sh, pr-helpers.sh, rebase.sh,
  status.sh, deploy.sh, regtest.sh, tmux-launch.sh.
- Stripped aiolabs/forgejo/bitspire/lamassu/webapp hardcoding; the
  github-fork remote is renamed 'fork' to match git.remotes vocabulary.
- Removes the dev.sh stub (the matured impl uses discrete commands +
  shell functions, not a unified 'dev' CLI).

presets/example.nix: a worked, generic project list replacing the
identity-specific aiolabs preset. tests/smoke.nix + flake checks
exercise the schema; 'nix flake check' is green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 21:18:49 +02:00

290 lines
9.3 KiB
Bash

#!/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}/<name>.git with the declared remotes
# (origin, optional upstream, optional fork).
# 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
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 -----------------------------------------------------------
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BLUE='\033[0;34m'
DIM='\033[2m'
NC='\033[0m'
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 the dev-env module 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