Git-Server git · main
gitsync + this read-only git browser
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.gzgitsync 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"