Nimbin[12]?Web & Tools / Git-Server / gitsync

Git-Server git · main

gitsync + this read-only git browser

git php bash apache self-hosted · first commit 2026-10-05 · last commit 2026-10-06 (3 days ago) · synced 3 days ago

PHP 77.1% CSS 11.4% Markdown 8.6% Makefile 2.9%
git clone https://git.christianimmanuel.de/web-tools/Git-Server.gitwget https://git.christianimmanuel.de/web-tools/Git-Server/archive/Git-Server.tar.gz
gitsync 22.6 KB · 402 lines raw
#!/bin/bash
# gitsync - push marked local repos to a read-only git browser server.
# 100% Vibecode but tested.
set -eu

VERSION="1.12.0"
CONF="${GITSYNC_CONF:-$HOME/.config/gitsync/gitsync.conf}"
STAGE="${GITSYNC_STAGE:-$HOME/.cache/gitsync}"
DRYRUN=0 LOCAL=0 VERBOSE=0 FORCE=0 LIST=0 QUIET=0 ROOTNOW=0 SU_TRIED=0 SSH_KEY="" COLLECT_ROOT="" SECRETS_PASS=""
TMPS=(); trap 'rm -rf "${TMPS[@]:-}"' EXIT
SELF=$(readlink -f "$0")
# always the real git binary, never a shell alias/function/wrapper (GIT= in config overrides)
GIT=$(command -v /usr/bin/git || command -v /bin/git || echo git)
# build junk never published from plain (non-git) directories; EXCLUDE= in the config adds more
MAX_SIZE="50m"   # plain dirs: files above this are skipped (MAX_SIZE= in config)
EXCLUDES=".git CMakeFiles CMakeCache.txt cmake_install.cmake *.o *.a *.so *.swp *.swo *.pyc __pycache__ .idea .ipynb_checkpoints massif.out.* core"

usage() {
cat <<USAGE
gitsync $VERSION - sync repos listed in $CONF

  gitsync            stage all repos and upload to server
  gitsync -n         dry run (show what would happen)
  gitsync -l         only stage locally, no upload
  gitsync -c         list configured repos
  gitsync -f         publish even if the secret scan finds something
  gitsync -v         verbose rsync/git output
  gitsync -q         quiet (only warnings; for cron)
  gitsync -r         collect root-only backup.conf files now (asks for the root password)
  gitsync --collect-root USER   as root (cron): collect them for USER
  gitsync -h         this help
  gitsync -V         version

Config: HOST=user@server, REMOTE_DIR=/var/www/html/site, then
[Category] sections with lines:  /path | description | tag1, tag2
Prefix a line with ! to publish it under /private (HTTP auth, no secret scan).
A line  !NAME < /etc/pkgusr/backup.conf  publishes the files an lfs-backup config lists
(private; secret files left out unless -f).
Optional: SSH_KEY=~/.ssh/gitsync_ed25519 (key for the upload, no password prompt),
          SCRIPTS=~/Bash-Public (script collection, subdirs = groups),
          EXCLUDE=a,b (extra excludes), MAX_SIZE=50m (skip bigger files),
          STAGE=~/other/dir (staging dir, default ~/.cache/gitsync),
          SECRETS_PASS=~/.config/gitsync/secrets.pass (backup.conf secrets go up
          encrypted as secrets-*.tar.enc instead of being left out)
USAGE
}

log()  { [ "$QUIET" = 1 ] || printf '\033[1;34m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
die()  { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
q()    { [ "$VERBOSE" = 1 ] && echo "" || echo "-q"; }
slug() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-//; s/-$//'; }
trim() { local s="$1"; s="${s#"${s%%[![:space:]]*}"}"; s="${s%"${s##*[![:space:]]}"}"; printf '%s' "$s"; }

case "${1:-}" in
  --version|-V) echo "gitsync $VERSION"; exit 0 ;; --help) usage; exit 0 ;;
  --collect-root) COLLECT_ROOT="${2:-}"; [ -n "$COLLECT_ROOT" ] || die "usage: gitsync --collect-root USER"; shift 2 ;;
esac
while getopts "nlcfvqrh" o; do
  case $o in
    n) DRYRUN=1 ;; r) ROOTNOW=1 ;; l) LOCAL=1 ;; c) LIST=1 ;; f) FORCE=1 ;; v) VERBOSE=1 ;; q) QUIET=1 ;;
    h) usage; exit 0 ;; *) usage; exit 1 ;;
  esac
done
if [ -z "$COLLECT_ROOT" ]; then
  [ -f "$CONF" ] || die "no config at $CONF (see gitsync.conf example)"
  log "config: $CONF"
fi

# --- secret scan -------------------------------------------------------------
# content patterns (case-insensitive ERE) and file name patterns
SECRET_RE='(AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----|ghp_[A-Za-z0-9]{36}|glpat-[A-Za-z0-9_-]{20}|xox[baprs]-[A-Za-z0-9-]{10,}|sk-[A-Za-z0-9]{32,}|(api[_-]?key|secret[_-]?key|access[_-]?token|auth[_-]?token|password|passwd)[[:space:]]*[=:][[:space:]]*["'"'"'][^"'"'"']{8,}["'"'"'])'
SECRET_FILES='(^|/)(\.env([./].*)?|id_(rsa|dsa|ecdsa|ed25519)|.*\.(pem|key|p12|pfx|kdbx)|.*(secret|credential)s?[^/]*)$'

scan_secrets() { # src kind -> prints findings, returns 1 if any
  local src="$1" kind="$2" hits
  if [ "$kind" = git ]; then
    hits=$( { "$GIT" -C "$src" ls-files | grep -iE "$SECRET_FILES" | grep -viE 'public|example|sample|template' | sed 's/^/file: /';
             "$GIT" -C "$src" grep -I -i -n -E -e "$SECRET_RE" $("$GIT" -C "$src" rev-list --all 2>/dev/null | head -500) -- . 2>/dev/null | cut -c1-160 | sort -u | head -20; } || true )
  else
    hits=$( { find "$src" -type f -not -name .gitsync.meta | sed "s|^$src/||" | grep -iE "$SECRET_FILES" | grep -viE 'public|example|sample|template' | sed 's/^/file: /';
             grep -rIinE --exclude-dir=.git -e "$SECRET_RE" "$src" 2>/dev/null | sed "s|^$src/||" | cut -c1-160 | head -20; } || true )
  fi
  hits=$(printf '%s\n' "$hits" | grep -vE '[=:][[:space:]]*["'"'"']?[A-Z][A-Z0-9_]{4,}["'"'"']?[,;)]?[[:space:]]*$' \
       | grep -E 'file: |BEGIN |AKIA|ghp_|glpat-|xox[baprs]-|sk-|[=:][[:space:]]*["'"'"'][A-Za-z_/+.=-]*[0-9]' || true)
  [ -z "$hits" ] && return 0
  printf '%s\n' "$hits" | sed 's/^/    /' >&2
  return 1
}

# --- language stats -----------------------------------------------------------
lang_stats() { # reads "size path" lines on stdin -> "C:1234,Shell:99"
  awk '
  function lang(p,  b, e) { b=p; sub(/.*\//,"",b); e=tolower(b); sub(/.*\./,"",e);
    if (tolower(b)=="makefile"||e=="mk") return "Makefile";
    if (e=="c"||e=="h") return "C"; if (e=="cpp"||e=="cc"||e=="cxx"||e=="hpp"||e=="hh") return "C++";
    if (e=="py") return "Python"; if (e=="sh"||e=="bash") return "Shell"; if (e=="js"||e=="mjs") return "JavaScript";
    if (e=="ts") return "TypeScript"; if (e=="php") return "PHP"; if (e=="html"||e=="htm") return "HTML";
    if (e=="css") return "CSS"; if (e=="rs") return "Rust"; if (e=="go") return "Go"; if (e=="java") return "Java";
    if (e=="vim") return "Vim Script"; if (e=="lua") return "Lua"; if (e=="glsl"||e=="vert"||e=="frag") return "GLSL";
    if (e=="s"||e=="asm") return "Assembly"; if (e=="md") return "Markdown"; if (e=="rb") return "Ruby";
    if (e=="pl") return "Perl"; if (e=="cs") return "C#"; if (e=="kt") return "Kotlin"; if (e=="swift") return "Swift";
    if (e=="tex") return "TeX"; return "" }
  { l=lang($2); if (l!="") s[l]+=$1 }
  END { for (l in s) printf "%s:%d\n", l, s[l] }' | sort -t: -k2 -nr | paste -sd, -
}

write_meta() { # file desc tags origin created modified languages
  printf 'description=%s\ntags=%s\norigin=%s\ncreated=%s\nmodified=%s\nlanguages=%s\nsynced=%s\n' "$2" "$3" "$4" "$5" "$6" "$7" "$(date +%s)" > "$1"
}

sync_git() { # src bare desc tags
  local src="$1" bare="$2" branch origin created modified langs
  [ -d "$bare" ] || { "$GIT" init -q --bare "$bare"; rm -f "$bare"/hooks/*.sample; }
  "$GIT" -C "$src" push $(q) --force --prune "$bare" 'refs/heads/*:refs/heads/*' 'refs/tags/*:refs/tags/*' \
    || { warn "push failed for $src (no commits?)"; return 0; }
  branch=$("$GIT" -C "$src" symbolic-ref --short HEAD 2>/dev/null || true)
  [ -n "$branch" ] || branch=$("$GIT" -C "$bare" for-each-ref --format='%(refname:short)' refs/heads | head -n1)
  [ -n "$branch" ] && "$GIT" -C "$bare" symbolic-ref HEAD "refs/heads/$branch"
  "$GIT" -C "$bare" update-server-info
  origin=$("$GIT" -C "$src" remote get-url origin 2>/dev/null | sed -E 's#^(ssh://)?git@([^:/]+)[:/]#https://\2/#; s#\.git$##' || true)
  created=$("$GIT" -C "$src" log --reverse --format=%at | head -n1)
  modified=$("$GIT" -C "$src" log -1 --format=%at)
  langs=$("$GIT" -C "$src" ls-tree -r -l HEAD | awk '{print $4, $5}' | lang_stats)
  write_meta "$bare/gitsync.meta" "$3" "$4" "$origin" "$created" "$modified" "$langs"
}

sync_plain() { # src dst desc tags
  local src="$1" dst="$2" created modified langs ex=() f
  mkdir -p "$dst"
  for f in $EXCLUDES; do ex+=(--exclude="$f"); done
  # compiled binaries (ELF) are skipped too
  while IFS= read -r -d '' f; do
    [ "$(head -c4 "$f" 2>/dev/null | tail -c3 | tr -d '\0')" = "ELF" ] && ex+=(--exclude="/${f#"$src/"}")
  done < <(find -L "$src" -type f -not -path '*/.git/*' -size +0 -print0 2>/dev/null)
  # honour .gitignore files like git would; symlinks are followed and their target content copied
  rsync -aL --delete --max-size="$MAX_SIZE" --filter=':- .gitignore' "${ex[@]}" "$src/" "$dst/" \
    || { rc=$?; [ "$rc" = 23 ] || [ "$rc" = 24 ] || return "$rc"; warn "$(basename "$src"): some files skipped (dangling symlink?)"; }
  big=$(find -L "$src" -type f -not -path '*/.git/*' -size +"$MAX_SIZE" 2>/dev/null | wc -l)
  [ "$big" -gt 0 ] && warn "$(basename "$src"): $big file(s) over $MAX_SIZE skipped"
  created=$(find "$dst" -type f -not -name .gitsync.meta -printf '%T@\n' | sort -n | head -n1 | cut -d. -f1)
  modified=$(find "$dst" -type f -not -name .gitsync.meta -printf '%T@\n' | sort -n | tail -n1 | cut -d. -f1)
  langs=$(find "$dst" -type f -printf '%s %p\n' | lang_stats)
  write_meta "$dst/.gitsync.meta" "$3" "$4" "" "$created" "$modified" "$langs"
}

# --- lfs-backup projects:  !NAME < /etc/pkgusr/backup.conf ------------------
# Publishes the files a backup.conf lists, privately. Layout: SECTION/path,
# e.g. user-shell/.bashrc, system-boot/etc/fstab. Secret files are left out (-f takes them).
# [root:*] and [system:*] files are collected as root (cron, or su in a terminal) into
# CACHE_DIR/USER.tar; the normal run takes them from there and adds the [user:*] files itself.
CACHE_DIR=/var/cache/gitsync
is_secret() { # file
  printf '%s\n' "$1" | grep -qiE -e "$SECRET_FILES" -e '(^|/)ssh_host_[a-z0-9]+_key$' && return 0
  grep -qiIE -e "$SECRET_RE" -e '^[[:space:]]*(psk|password|private_key_passwd)[[:space:]]*=' "$1" 2>/dev/null
}
has_tty() { [ -t 2 ] && { : </dev/tty; } 2>/dev/null; }
cache_fresh() { # tarball backup.conf: younger than a day and newer than the config (and passphrase)
  [ -r "$1" ] && [ "$1" -nt "$2" ] && [ $(( $(date +%s) - $(stat -c %Y "$1") )) -lt 86400 ] \
    && { [ -z "$SECRETS_PASS" ] || [ ! -e "$SECRETS_PASS" ] || [ "$1" -nt "$SECRETS_PASS" ]; }
}

# secret files go into B_SECDIR (if set) and are sealed into DST/secrets-LABEL.tar.enc
ENC="enc -aes-256-cbc -pbkdf2 -iter 600000"
secrets_dir() { # -> sets B_SECDIR when SECRETS_PASS is usable
  B_SECDIR=""
  [ -n "$SECRETS_PASS" ] || return 0
  [ -s "$SECRETS_PASS" ] && [ -r "$SECRETS_PASS" ] || { warn "SECRETS_PASS: $SECRETS_PASS missing or empty, secrets left out"; return 0; }
  command -v openssl >/dev/null || { warn "openssl not found, secrets left out"; return 0; }
  B_SECDIR=$(mktemp -d); TMPS+=("$B_SECDIR")
}
seal_secrets() { # dst label
  [ -n "$B_SECDIR" ] && [ "${#B_SECRETS[@]}" -gt 0 ] || return 0
  # shellcheck disable=SC2086
  tar -C "$B_SECDIR" -cf - . | openssl $ENC -salt -pass "file:$SECRETS_PASS" -out "$1/secrets-$2.tar.enc"
  chmod 600 "$1/secrets-$2.tar.enc"; rm -rf "$B_SECDIR"
}

collect_backup() { # conf dst kinds(all|user|rootsys) -> appends to B_SECRETS / B_UNREAD
  local conf="$1" dst="$2" kinds="$3" line sec="" home="" rhome p m f rel pat d skip out ex=() force="$FORCE"
  [ "$kinds" = rootsys ] && force=0   # the root side never hands out secrets
  rhome=$(getent passwd root | cut -d: -f6); rhome="${rhome:-/root}"
  while IFS= read -r line || [ -n "$line" ]; do
    line=$(trim "$line"); [ "${line:0:1}" = "!" ] && ex+=("${line#!}")
  done < "$conf"
  shopt -s dotglob
  while IFS= read -r line || [ -n "$line" ]; do
    line=$(trim "$line")
    case "$line" in
      ''|\#*|\!*) continue ;;
      \[user:*\])   sec="user-${line:6:-1}";   home="$HOME"
                    [ "$kinds" = rootsys ] && sec="" ;;
      \[root:*\])   sec="root-${line:6:-1}";   home="$rhome"
                    if [ "$kinds" = user ]; then sec=""
                    elif ! { [ -r "$rhome" ] && [ -x "$rhome" ]; }; then B_UNREAD+=("[root:${line:6:-1}]"); sec=""; fi ;;
      \[system:*\]) sec="system-${line:8:-1}"; home=""
                    [ "$kinds" = user ] && sec="" ;;
      \[*)          warn "$(basename "$conf"): unknown section $line"; sec="" ;;
      *)
        [ -n "$sec" ] || continue
        p="$line"; [ -n "$home" ] && p="${p/#\~/$home}"
        m=$(compgen -G "$p" || true)
        if [ -z "$m" ]; then
          d=$(dirname "$p"); [ -d "$d" ] && { [ -r "$d" ] && [ -x "$d" ] || B_UNREAD+=("$line"); }
          continue
        fi
        while IFS= read -r m; do
          while IFS= read -r -d '' f; do
            skip=0
            for pat in "${ex[@]:-}"; do
              [ -n "$pat" ] || continue
              pat="${pat/#\~/${home:-$HOME}}"
              # shellcheck disable=SC2053
              [[ "$f" == $pat ]] && { skip=1; break; }
            done
            [ "$skip" = 1 ] && continue
            if [ ! -r "$f" ]; then B_UNREAD+=("$f"); continue; fi
            out="$dst"
            if [ "$force" = 0 ] && is_secret "$f"; then
              B_SECRETS+=("$f"); [ -n "$B_SECDIR" ] || continue; out="$B_SECDIR"
            fi
            rel="${f#"$home"/}"; rel="${rel#/}"
            mkdir -p "$out/$sec/$(dirname "$rel")"
            cp -L --preserve=timestamps "$f" "$out/$sec/$rel"
          done < <(find -L "$m" -type f -print0 2>/dev/null)
        done <<< "$m" ;;
    esac
  done < "$conf"
  shopt -u dotglob
}

report_backup() { # name hint [sealed-file]
  if [ "${#B_SECRETS[@]}" -gt 0 ] && [ -n "${3:-}" ]; then log "  ${#B_SECRETS[@]} secret file(s) encrypted into $3"
  elif [ "$QUIET" = 0 ] && [ "${#B_SECRETS[@]}" -gt 0 ]; then
    warn "$1: ${#B_SECRETS[@]} secret file(s) left out (SECRETS_PASS= uploads them encrypted): $(printf '%s\n' "${B_SECRETS[@]}" | sed "s|^$HOME/|~/|" | paste -sd' ' -)"
  fi
  [ "${#B_UNREAD[@]}" = 0 ] || warn "$1: not readable as $(id -un), left out: $(printf '%s\n' "${B_UNREAD[@]}" | sed "s|^$HOME/|~/|" | paste -sd' ' -)$2"
}

sync_backup() { # conf dst desc tags name
  local conf="$1" dst="$2" name="$5" cache="$CACHE_DIR/$(id -un).tar" kinds=all
  B_SECRETS=() B_UNREAD=()
  rm -rf "$dst"; mkdir -p "$dst"
  if [ "$(id -u)" != 0 ] && [ "$SU_TRIED" = 0 ] && has_tty && { [ "$ROOTNOW" = 1 ] || ! cache_fresh "$cache" "$conf"; }; then
    SU_TRIED=1
    log "root password to collect the root-only files (enter = skip)"
    su root -c "$(printf '%q' "$SELF") --collect-root $(printf '%q' "$(id -un)")" </dev/tty || warn "su failed, root-only files left out"
  fi
  if cache_fresh "$cache" "$conf" && tar -C "$dst" --strip-components=1 -xf "$cache" "$name" 2>/dev/null; then
    kinds=user; log "  root/system files collected $(date -r "$cache" '+%F %H:%M')"
  fi
  secrets_dir
  collect_backup "$conf" "$dst" "$kinds"
  seal_secrets "$dst" user
  report_backup "$name" "$([ "$kinds" = all ] && echo ' (cron collects them as root; in a terminal: gitsync -r)')" \
    "$([ -f "$dst/secrets-user.tar.enc" ] && echo secrets-user.tar.enc)"
  write_meta "$dst/.gitsync.meta" "$3" "$4" "" \
    "$(find "$dst" -type f -not -name .gitsync.meta -printf '%T@\n' | sort -n | head -n1 | cut -d. -f1)" \
    "$(find "$dst" -type f -not -name .gitsync.meta -printf '%T@\n' | sort -n | tail -n1 | cut -d. -f1)" \
    "$(find "$dst" -type f -printf '%s %p\n' | lang_stats)"
}

owned_by_root() { # path: root owned, not writable by group/others (a dir may be, with the sticky bit)
  local s m; s=$(stat -c '%u %a' "$1") || return 1; m=$(( 8#${s##* } ))
  [ "${s%% *}" = 0 ] || return 1
  [ $(( m & 8#022 )) = 0 ] || { [ -d "$1" ] && [ $(( m & 8#1000 )) != 0 ]; }
}

collect_root() { # USER: as root, collect [root:*]/[system:*] files of USER's backup.conf projects
  local u="$1" uhome conf line path bconf name p n=0
  [ "$(id -u)" = 0 ] || die "--collect-root must run as root"
  uhome=$(getent passwd "$u" | cut -d: -f6); [ -n "$uhome" ] || die "no such user: $u"
  conf="$uhome/.config/gitsync/gitsync.conf"; [ -f "$conf" ] || die "no config at $conf"
  [ -L "$CACHE_DIR" ] && die "$CACHE_DIR is a symlink"
  install -d -m 755 -o root -g root "$CACHE_DIR"
  owned_by_root "$CACHE_DIR" || die "$CACHE_DIR must be owned by root and not writable by others"
  CR_TMP=$(mktemp -d); TMPS+=("$CR_TMP")
  # passphrase file named in the user's config (only used as the key, never published)
  SECRETS_PASS=$(sed -n 's/^[[:space:]]*SECRETS_PASS=//p' "$conf" | tail -n1); SECRETS_PASS=$(trim "${SECRETS_PASS%%#*}")
  SECRETS_PASS="${SECRETS_PASS/#\~/$uhome}"
  while IFS= read -r line || [ -n "$line" ]; do
    line="${line%%#*}"; line=$(trim "$line"); line="${line#!}"
    path=$(trim "${line%%|*}"); [[ "$path" == *"<"* ]] || continue
    bconf=$(trim "${path#*<}"); bconf="${bconf/#\~/$uhome}"; name=$(trim "${path%%<*}")
    [ -n "$name" ] || name=$(basename "$bconf" .conf)
    [[ "$name" =~ ^[A-Za-z0-9_+-][A-Za-z0-9._+-]*$ ]] || { warn "bad name: $name"; continue; }
    bconf=$(readlink -f "$bconf" || true)
    # the file list must come from root, not from the user's config
    if [ ! -f "$bconf" ]; then warn "$bconf: missing, skipped"; continue; fi
    for p in "$bconf" "$(dirname "$bconf")"; do
      owned_by_root "$p" || { warn "$name skipped: $p must be owned by root and not writable by others ($(stat -c '%U:%G %A' "$p"))"; continue 2; }
    done
    B_SECRETS=() B_UNREAD=(); secrets_dir
    mkdir -p "$CR_TMP/$name"; collect_backup "$bconf" "$CR_TMP/$name" rootsys; n=$((n + 1))
    seal_secrets "$CR_TMP/$name" root
    report_backup "$name (root)" "" "$([ -f "$CR_TMP/$name/secrets-root.tar.enc" ] && echo secrets-root.tar.enc)"
  done < "$conf"
  if [ "$n" = 0 ]; then rm -f "$CACHE_DIR/$u.tar"; log "no backup.conf projects for $u"; return 0; fi
  (cd "$CR_TMP" && tar -cf "$CACHE_DIR/.$u.tar.new" -- *)
  chown "$u" "$CACHE_DIR/.$u.tar.new"; chmod 600 "$CACHE_DIR/.$u.tar.new"
  mv -f "$CACHE_DIR/.$u.tar.new" "$CACHE_DIR/$u.tar"
  log "root-only files for $u: $CACHE_DIR/$u.tar"
}

[ -n "$COLLECT_ROOT" ] && { collect_root "$COLLECT_ROOT"; exit 0; }

HOST="" REMOTE_DIR="" SCRIPTS="" cat="" cslug="" BLOCKED=0
mkdir -p "$STAGE"
: > "$STAGE/.categories.new"
declare -a KEEP=()

while IFS= read -r line || [ -n "$line" ]; do
  line="${line%%#*}"; line=$(trim "$line")
  [ -z "$line" ] && continue
  case "$line" in
    HOST=*)       HOST="${line#HOST=}" ;;
    REMOTE_DIR=*) REMOTE_DIR="${line#REMOTE_DIR=}" ;;
    MAX_SIZE=*)   MAX_SIZE="${line#MAX_SIZE=}" ;;
    GIT=*)        GIT="${line#GIT=}" ;;
    SSH_KEY=*)    SSH_KEY="${line#SSH_KEY=}"; SSH_KEY="${SSH_KEY/#\~/$HOME}" ;;
    SCRIPTS=*)    SCRIPTS="${line#SCRIPTS=}"; SCRIPTS="${SCRIPTS/#\~/$HOME}" ;;
    SECRETS_PASS=*) SECRETS_PASS="${line#SECRETS_PASS=}"; SECRETS_PASS="${SECRETS_PASS/#\~/$HOME}" ;;
    STAGE=*)      STAGE="${line#STAGE=}"; STAGE="${STAGE/#\~/$HOME}"; mkdir -p "$STAGE" ;;
    EXCLUDE=*)    EXCLUDES="$EXCLUDES $(printf '%s' "${line#EXCLUDE=}" | tr ',' ' ')" ;;
    \[*\])
      cat=$(trim "${line#[}"); cat=$(trim "${cat%]}"); cslug=$(slug "$cat")
      printf '%s|%s\n' "$cslug" "$cat" >> "$STAGE/.categories.new"
      mkdir -p "$STAGE/$cslug" ;;
    *)
      [ -n "$cslug" ] || die "repo line before any [Category]: $line"
      private=0; [ "${line:0:1}" = "!" ] && { private=1; line="${line#!}"; }
      IFS='|' read -r path desc tags <<< "$line"
      path=$(trim "$path"); desc=$(trim "${desc:-}"); tags=$(trim "${tags:-}")
      tags=$(printf '%s' "$tags" | tr ',' '\n' | sed 's/^ *//; s/ *$//' | grep -v '^$' | tr '[:upper:]' '[:lower:]' | paste -sd, - || true)
      if [[ "$path" == *"<"* ]]; then   # NAME < backup.conf: always private
        bconf=$(trim "${path#*<}"); bconf="${bconf/#\~/$HOME}"; name=$(trim "${path%%<*}")
        [ -n "$name" ] || name=$(basename "$bconf" .conf)
        [ -r "$bconf" ] || { warn "missing or not readable: $bconf"; continue; }
        if [ "$LIST" = 1 ]; then printf '%-22s %-45s %-40s %s  [private, backup.conf]\n' "[$cat]" "$name < $bconf" "$desc" "$tags"; continue; fi
        dest="$STAGE/.private/$cslug/$name"; mkdir -p "$STAGE/.private/$cslug"
        log "$cat / $name (backup.conf, private)"
        [ "$DRYRUN" = 1 ] || sync_backup "$bconf" "$dest" "$desc" "$tags" "$name"
        KEEP+=("$dest"); continue
      fi
      path="${path/#\~/$HOME}"
      name=$(basename "$path")
      if [ ! -d "$path" ]; then warn "missing: $path"; continue; fi
      if [ "$LIST" = 1 ]; then printf '%-22s %-45s %-40s %s%s\n' "[$cat]" "$path" "$desc" "$tags" "$([ $private = 1 ] && echo '  [private]')"; continue; fi
      root="$STAGE"; [ "$private" = 1 ] && { root="$STAGE/.private"; mkdir -p "$root/$cslug"; }
      if [ -d "$path/.git" ]; then dest="$root/$cslug/$name.git"; kind="git"
      else dest="$root/$cslug/$name"; kind="files"; fi
      log "$cat / $name ($kind$([ $private = 1 ] && echo ', private'))"
      # git: scan tracked files + history; files: stage first, then scan exactly what would be published
      if [ "$kind" = git ]; then scan_target="$path"; else [ "$DRYRUN" = 1 ] || sync_plain "$path" "$dest" "$desc" "$tags"; scan_target="$dest"; fi
      if [ "$private" = 0 ] && [ -d "$scan_target" ] && ! scan_secrets "$scan_target" "$kind"; then
        if [ "$FORCE" = 1 ]; then warn "possible secrets in $name, publishing anyway (-f)"
        else warn "possible secrets in $name, SKIPPED (fix it or use -f)"; BLOCKED=1; rm -rf "$dest"; continue; fi
      fi
      KEEP+=("$dest")
      [ "$DRYRUN" = 1 ] && continue
      [ "$kind" = git ] && sync_git "$path" "$dest" "$desc" "$tags"
      ;;
  esac
done < "$CONF"

# scripts collection (SCRIPTS=dir): subdirs = groups, published as files under /scripts
if [ -n "$SCRIPTS" ] && [ -d "$SCRIPTS" ]; then
  log "scripts ($SCRIPTS)"
  if [ "$DRYRUN" = 0 ]; then
    sync_plain "$SCRIPTS" "$STAGE/.scripts" "Scripts" ""
    if ! scan_secrets "$STAGE/.scripts" files; then
      if [ "$FORCE" = 1 ]; then warn "possible secrets in scripts, publishing anyway (-f)"
      else warn "possible secrets in scripts, SKIPPED (fix it or use -f)"; BLOCKED=1; rm -rf "$STAGE/.scripts"; fi
    fi
  fi
elif [ -z "$SCRIPTS" ]; then rm -rf "$STAGE/.scripts"; fi

[ "$LIST" = 1 ] && { rm -f "$STAGE/.categories.new"; exit 0; }

if [ "$DRYRUN" = 0 ]; then
  mv "$STAGE/.categories.new" "$STAGE/.categories"
  date +%s > "$STAGE/.synced"
  while IFS= read -r d; do
    keep=0; for k in "${KEEP[@]:-}"; do [ "$k" = "$d" ] && keep=1; done
    [ "$keep" = 0 ] && { log "prune $(basename "$(dirname "$d")")/$(basename "$d")"; rm -rf "$d"; }
  done < <(find "$STAGE" -mindepth 2 -maxdepth 2 -type d -not -path "$STAGE/.scripts/*" -not -path "$STAGE/.private/*"; [ -d "$STAGE/.private" ] && find "$STAGE/.private" -mindepth 2 -maxdepth 2 -type d)
  while IFS= read -r d; do
    grep -q "^$(basename "$d")|" "$STAGE/.categories" || rm -rf "$d"
  done < <(find "$STAGE" -mindepth 1 -maxdepth 1 -type d -not -name .scripts -not -name .private)
else
  rm -f "$STAGE/.categories.new"
fi

[ "$BLOCKED" = 1 ] && warn "some repos were skipped because of the secret scan"
[ "$LOCAL" = 1 ] && { log "staged in $STAGE"; exit 0; }
[ -n "$HOST" ] && [ -n "$REMOTE_DIR" ] || die "HOST and REMOTE_DIR must be set in config"

log "upload to $HOST:$REMOTE_DIR/repos/"
extra=""; [ "$DRYRUN" = 1 ] && extra="-n"; [ "$VERBOSE" = 1 ] && extra="$extra -v"
SSH="ssh -o BatchMode=yes"; [ -n "$SSH_KEY" ] && SSH="$SSH -i $SSH_KEY"
rsync -az --delete --chmod=D755,F644 -e "$SSH" $extra "$STAGE/" "$HOST:$REMOTE_DIR/repos/"
log "done"