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>
This commit is contained in:
parent
773632562e
commit
e38d313db2
17 changed files with 2925 additions and 147 deletions
290
modules/dev-env/scripts/bootstrap.sh
Normal file
290
modules/dev-env/scripts/bootstrap.sh
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
#!/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
|
||||
Reference in a new issue