gitpages.sh (12253B)
1 #!/bin/sh 2 # 3 # Copyright (c) 2026 Fred Großkopf 4 # 5 # Permission to use, copy, modify, and/or distribute this software for any 6 # purpose with or without fee is hereby granted, provided that the above 7 # copyright notice and this permission notice appear in all copies. 8 # 9 # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 17 set -eu 18 19 SCRIPT_DIR="$(dirname "$0")" 20 DEFAULT_CONFIG="/etc/gitpages.conf" 21 LOCK_DIR="${GITPAGES_LOCK_DIR:-/tmp/gitpages-update.lock}" 22 QUEUE_DIR="${GITPAGES_QUEUE_DIR:-/var/spool/gitpages/queue}" 23 24 usage() { 25 cat << EOF 26 gitpages - generate static Git web pages using stagit 27 28 Usage: 29 gitpages setup SRC 30 gitpages process_queue 31 gitpages mirror SRC DST 32 gitpages generate [REPO] 33 34 Arguments: 35 SRC 36 Source directory containing bare Git repositories. 37 These are the repositories that Git clients push to. 38 39 DST 40 Destination directory containing web-facing bare Git repositories. 41 These repositories are served via HTTP (for example git-raw/). 42 43 REPO 44 Path to a single bare Git repository. 45 46 Commands: 47 setup SRC 48 Prepare repositories for gitpages: 49 install hooks and configure metadata. 50 51 queue REPO 52 Add a repository to the update queue. 53 54 process_queue 55 Process queued repository updates. 56 57 generate [REPO] 58 Generate stagit HTML pages. 59 If REPO is omitted, generate pages for all repositories. 60 61 EOF 62 exit 1 63 } 64 65 # --- Helper Functions --- 66 get_config_val() { 67 awk -F '=' -v key="$1" ' 68 $1 ~ /^[[:space:]]*[a-zA-Z0-9_]+[[:space:]]*$/ && $1 == key { 69 gsub(/[[:space:]]+$/, "", $2); 70 gsub(/^[[:space:]]+/, "", $2); 71 print $2; 72 exit; 73 } 74 ' "$CONFIG" 75 } 76 77 error() { 78 printf 'error: %s\n' "$1" >&2 79 exit 1 80 } 81 info() { printf 'info: %s\n' "$1"; } 82 warning() { printf 'warning: %s\n' "$1" >&2; } 83 sed_escape() { printf '%s\n' "$1" | sed 's/[\\/&]/\\&/g'; } 84 85 check_deps() { 86 command -v stagit > /dev/null 2>&1 || error "stagit not found" 87 command -v stagit-index > /dev/null 2>&1 || error "stagit-index not found" 88 command -v git > /dev/null 2>&1 || error "git not found" 89 } 90 91 is_bare_repo() { 92 git --git-dir="$1" rev-parse --is-bare-repository > /dev/null 2>&1 93 } 94 95 has_repos() { 96 for repo in "$1"/*.git; do 97 [ -d "$repo" ] && return 0 98 done 99 return 1 100 } 101 102 load_config() { 103 CONFIG="$1" 104 105 [ -r "$CONFIG" ] \ 106 || error "config $CONFIG not readable" 107 108 WEB_ROOT=$(get_config_val "WEB_ROOT") 109 GIT_SRC=$(get_config_val "GIT_SRC") 110 GIT_RAW=$(get_config_val "GIT_RAW") 111 GIT_HTML=$(get_config_val "GIT_HTML") 112 INDEX_TITLE=$(get_config_val "INDEX_TITLE") 113 INDEX_DESC=$(get_config_val "INDEX_DESCRIPTION") 114 REPO_TITLE_DESC=$(get_config_val "REPO_TITLE_DESCRIPTION") 115 ASSETS_DIR=$(get_config_val "ASSETS_DIR") 116 117 [ -n "$WEB_ROOT" ] || error "WEB_ROOT missing in config" 118 [ -n "$GIT_SRC" ] || error "GIT_SRC missing in config" 119 [ -n "$GIT_RAW" ] || error "GIT_RAW missing in config" 120 [ -n "$GIT_HTML" ] || error "GIT_HTML missing in config" 121 } 122 123 # --- Core Logic --- 124 125 generate__run_stagit() { 126 rm -rf "$STAGING_DIR" 127 mkdir -p "$STAGING_DIR" 128 cd "$STAGING_DIR" 129 stagit "$REPO_SRC" > /dev/null 2>&1 130 } 131 132 generate__update_repo_metadata() { 133 [ -n "${REPO_TITLE_DESC:-}" ] || return 0 134 esc_desc=$(sed_escape "$REPO_TITLE_DESC") 135 find "$STAGING_DIR" -name "*.html" -type f -exec sed -i \ 136 "s| - $REPO_BASE - [^<]*| - $REPO_BASE$esc_desc|g" {} + 137 } 138 139 generate__run_stagit_index() { 140 # 1. Verify destination path exists 141 [ -d "$WEB_ROOT/$GIT_HTML" ] || { 142 printf 'Error: %s/html not found\n' "$WEB_ROOT/$GIT_HTML" 143 return 1 144 } 145 146 # 2. Use a subshell to avoid persistent cd and ensure we run from the right place 147 ( 148 cd "$WEB_ROOT/$GIT_HTML" || exit 1 149 150 # 3. Check if we have repositories before running the command 151 # Use 'find' to safely handle the list of repos 152 repos=$(find "$SRC_DIR" -maxdepth 1 -name "*.git") 153 154 if [ -n "$repos" ]; then 155 stagit-index $repos > index.html 156 else 157 printf 'No repositories found in %s\n' "$SRC_DIR" 158 fi 159 ) 160 } 161 162 generate__update_index_metadata() { 163 [ -f "$WEB_ROOT/$GIT_HTML/index.html" ] || return 0 164 [ -n "${INDEX_TITLE:-}" ] && { 165 esc_title=$(sed_escape "$INDEX_TITLE") 166 sed -i "s|<title>Repositories</title>|<title>$esc_title</title>|g" "$WEB_ROOT/$GIT_HTML/index.html" 167 } 168 [ -n "${INDEX_DESC:-}" ] && { 169 esc_desc=$(sed_escape "$INDEX_DESC") 170 sed -i "s|<span class=\"desc\">Repositories</span>|<span class=\"desc\">$esc_desc</span>|g" "$WEB_ROOT/$GIT_HTML/index.html" 171 } 172 } 173 174 generate__copy_assets() { 175 [ -d "${ASSETS_DIR:-}" ] || return 0 176 mkdir -p "$STAGING_DIR" 177 for f in "$ASSETS_DIR"/*; do 178 [ -f "$f" ] && cp -L "$f" "$STAGING_DIR/$(basename "$f")" 179 done 180 } 181 182 generate__publish_repo() { 183 rm -rf "$TARGET_DIR" 2> /dev/null || true 184 mv "$STAGING_DIR" "$TARGET_DIR" || error "could not move staging into place" 185 } 186 187 generate_repo() { 188 load_config "$1" 189 REPO_SRC="$2" 190 191 REPO_BASE=$(basename "$REPO_SRC" ".git") 192 SRC_DIR="$WEB_ROOT/$GIT_RAW" 193 VAR_DIR="${GITPAGES_VAR_DIR:-/var/gitpages}" 194 TARGET_DIR="$WEB_ROOT/$GIT_HTML/$REPO_BASE" 195 STAGING_DIR="$VAR_DIR/staging/$REPO_BASE" 196 197 # Execution 198 generate__run_stagit 199 generate__copy_assets 200 generate__update_repo_metadata 201 generate__publish_repo 202 generate__run_stagit_index 203 generate__update_index_metadata 204 205 info "Generation complete for $REPO_BASE" 206 } 207 208 generate_all() { 209 load_config "$1" 210 211 [ -d "$WEB_ROOT" ] || error "WEB_ROOT directory $WEB_ROOT does not exist" 212 [ -d "$GIT_SRC" ] || error "GIT_SRC directory $GIT_SRC does not exist" 213 214 GIT_RAW_DIR="$WEB_ROOT/$GIT_RAW" 215 216 info "1/2 Mirroring repos $GIT_SRC → $GIT_RAW_DIR" 217 mirror "$GIT_SRC" "$GIT_RAW_DIR" 218 219 info "2/2 Generating HTML for all repos in $GIT_RAW_DIR" 220 221 for repo in "$GIT_RAW_DIR"/*.git; do 222 [ -e "$repo" ] || continue 223 224 info "Processing: $(basename "$repo")" 225 generate_repo "$CONFIG" "$repo" 226 done 227 } 228 229 setup__ask() { 230 prompt="$1" 231 current="${2:-}" 232 233 if [ -t 0 ]; then 234 if [ -n "$current" ]; then 235 printf '%s [%s]: ' "$prompt" "$current" >&2 236 else 237 printf '%s: ' "$prompt" >&2 238 fi 239 fi 240 241 read -r answer 242 case "$answer" in 243 "") printf '%s\n' "$current" ;; 244 *) printf '%s\n' "$answer" ;; 245 esac 246 } 247 248 setup__install_hook() { 249 repo_dir="$1" 250 251 hook_dir="$repo_dir/hooks" 252 hook_file="$hook_dir/post-receive" 253 254 mkdir -p "$hook_dir" 255 cp "$POST_RECEIVE" "$hook_file" 256 chmod 755 "$hook_file" 257 258 info "Installed post-receive hook in $repo_dir" 259 } 260 261 setup__edit_metadata() { 262 file="$1" 263 prompt="$2" 264 current="" 265 266 if [ -f "$file" ]; then 267 current=$(cat "$file") 268 fi 269 270 value=$(setup__ask "$prompt" "$current") 271 272 if [ -n "$value" ]; then 273 printf '%s\n' "$value" > "$file" 274 fi 275 } 276 277 setup_repo() { 278 repo="$1" 279 repo_name=$(basename "$repo" .git) 280 281 setup__edit_metadata "$repo/description" "Description" 282 setup__edit_metadata "$repo/owner" "Owner/Contact" 283 setup__edit_metadata "$repo/url" "URL" 284 285 printf '## Repository: %s ##\n' "$repo_name" 286 287 setup__install_hook "$repo" 288 } 289 290 setup_all() { 291 GIT_SRC="${1:-}" 292 293 if [ -z "$GIT_SRC" ]; then 294 CONFIG="$DEFAULT_CONFIG" 295 296 [ -r "$CONFIG" ] \ 297 || error "Config file $CONFIG not readable" 298 299 GIT_SRC=$(get_config_val "GIT_SRC") 300 301 [ -n "$GIT_SRC" ] \ 302 || error "GIT_SRC not set in $CONFIG" 303 fi 304 305 [ -d "$GIT_SRC" ] \ 306 || error "Directory '$GIT_SRC' not found" 307 308 POST_RECEIVE="$SCRIPT_DIR/gitpages.post-receive.hook" 309 310 [ -f "$POST_RECEIVE" ] \ 311 || error "post-receive helper not found in $SCRIPT_DIR" 312 313 for repo in "$GIT_SRC"/*.git; do 314 [ -d "$repo" ] || continue 315 setup_repo "$repo" 316 done 317 318 info "Setup complete for all repos in $GIT_SRC" 319 } 320 321 mirror__copy_repo() { 322 src="$1" 323 dst_dir="$2" 324 name=$(basename "$src") 325 dst="$dst_dir/$name" 326 327 is_bare_repo "$src" || { 328 warning "skipping $name (not bare)" 329 return 0 330 } 331 332 rm -rf "$dst" 333 cp -R "$src" "$dst" 334 git --git-dir="$dst" update-server-info > /dev/null 2>&1 335 } 336 337 mirror() { 338 src="$1" 339 dst_dir="$2" 340 341 [ "$dst_dir" != "/" ] \ 342 || error "refusing to use / as destination" 343 344 [ -d "$dst_dir" ] && [ -w "$dst_dir" ] \ 345 || error "DST_DIR '$dst_dir' not writable" 346 347 if is_bare_repo "$src"; then 348 mirror__copy_repo "$src" "$dst_dir" 349 info "synced: $src → $dst_dir" 350 elif has_repos "$src"; then 351 [ "$dst_dir" != "/" ] && rm -rf "$dst_dir"/* 352 353 for repo in "$src"/*.git; do 354 [ -d "$repo" ] || break 355 mirror__copy_repo "$repo" "$dst_dir" 356 done 357 358 info "synced all: $src → $dst_dir" 359 else 360 error "$src: neither bare repo nor *.git directory" 361 fi 362 } 363 364 process_repo() { 365 CONFIG="$1" 366 repo_path="$2" 367 368 repo_name=$(basename "$repo_path") 369 370 load_config "$CONFIG" 371 372 GIT_RAW_DIR="$WEB_ROOT/$GIT_RAW" 373 374 [ -d "$GIT_RAW_DIR" ] || { 375 printf 'error: GIT_RAW directory %s does not exist\n' "$GIT_RAW_DIR" >&2 376 return 1 377 } 378 379 if ! mirror "$repo_path" "$GIT_RAW_DIR"; then 380 printf 'error: Failed to mirror %s\n' "$repo_name" >&2 381 return 1 382 fi 383 384 if ! generate_repo "$CONFIG" "$GIT_RAW_DIR/$repo_name"; then 385 printf 'error: Failed to generate pages for %s\n' "$repo_name" >&2 386 return 1 387 fi 388 } 389 390 cmd_setup() { 391 case "$#" in 392 0) 393 setup_all 394 ;; 395 1) 396 setup_all "$1" 397 ;; 398 *) 399 error "usage: gitpages setup [SRC]" 400 ;; 401 esac 402 } 403 404 cmd_process_queue() { 405 CONFIG="$DEFAULT_CONFIG" 406 407 while [ "$#" -gt 0 ]; do 408 case "$1" in 409 -h) 410 printf 'usage: gitpages process_queue [-c config]\n' 411 return 0 412 ;; 413 -c) 414 [ "$#" -ge 2 ] || { 415 printf 'ERROR: -c requires a config file\n' >&2 416 exit 1 417 } 418 CONFIG="$2" 419 shift 2 420 ;; 421 -*) 422 printf 'ERROR: unknown option: %s\n' "$1" >&2 423 exit 1 424 ;; 425 *) 426 printf 'ERROR: too many arguments\n' >&2 427 exit 1 428 ;; 429 esac 430 done 431 432 [ -r "$CONFIG" ] || error "config $CONFIG not readable" 433 434 if ! mkdir "$LOCK_DIR" 2> /dev/null; then 435 info "Update already in progress. Exiting." 436 return 0 437 fi 438 439 trap 'rmdir "$LOCK_DIR"' EXIT 440 441 if [ ! -d "$QUEUE_DIR" ]; then 442 info "Queue directory not found: $QUEUE_DIR" 443 return 0 444 fi 445 446 processed=0 447 448 for jobfile in "$QUEUE_DIR"/*; do 449 [ -f "$jobfile" ] || continue 450 451 repo_path=$(cat "$jobfile") 452 453 if [ -d "$repo_path" ]; then 454 info "Starting process_repo for: $repo_path (job: $jobfile)" 455 456 if process_repo "$CONFIG" "$repo_path"; then 457 processed=$((processed + 1)) 458 rm -f "$jobfile" 459 else 460 printf 'error: failed to process %s (job: %s)\n' "$repo_path" "$jobfile" >&2 461 fi 462 else 463 printf 'error: Repository directory %s not found (job: %s)\n' \ 464 "$repo_path" "$jobfile" >&2 465 fi 466 done 467 468 if [ "$processed" -gt 0 ]; then 469 info "process_queue completed." 470 fi 471 } 472 473 cmd_generate() { 474 repo="" 475 config="" 476 477 while [ "$#" -gt 0 ]; do 478 case "$1" in 479 -h) 480 printf 'usage: gitpages generate [-c config] [REPO]\n' 481 return 0 482 ;; 483 -c) 484 [ "$#" -ge 2 ] || { 485 printf 'ERROR: -c requires a config file\n' >&2 486 exit 1 487 } 488 config="$2" 489 shift 2 490 ;; 491 -*) 492 printf 'ERROR: unknown option: %s\n' "$1" >&2 493 exit 1 494 ;; 495 *) 496 [ -z "$repo" ] || { 497 printf 'ERROR: too many arguments\n' >&2 498 exit 1 499 } 500 repo="$1" 501 shift 502 ;; 503 esac 504 done 505 506 [ -n "$config" ] || config="$DEFAULT_CONFIG" 507 508 check_deps 509 510 if [ -n "$repo" ]; then 511 generate_repo "$config" "$repo" 512 else 513 generate_all "$config" 514 fi 515 } 516 517 [ $# -gt 0 ] || usage 518 519 command="$1" 520 shift 521 522 case "$command" in 523 setup) 524 cmd_setup "$@" 525 ;; 526 process_queue) 527 cmd_process_queue "$@" 528 ;; 529 mirror) 530 cmd_mirror "$@" 531 ;; 532 generate) 533 cmd_generate "$@" 534 ;; 535 *) 536 usage 537 ;; 538 esac