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>
290 lines
9.3 KiB
Bash
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
|