#!/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 <\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/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/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"