gitpages

A collection of scripts to securely webhost and publish git repositories.
git clone https://scm.kuandu.systems/git-raw/gitpages.git
Log | Files | Refs | README | LICENSE

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