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
|
||||
Loading…
Add table
Add a link
Reference in a new issue