Skip to content

Instantly share code, notes, and snippets.

@jez500
Last active April 16, 2026 11:26
Show Gist options
  • Select an option

  • Save jez500/f41e2813389ca459c7f39dd4c4e1f386 to your computer and use it in GitHub Desktop.

Select an option

Save jez500/f41e2813389ca459c7f39dd4c4e1f386 to your computer and use it in GitHub Desktop.
PaperclipAi backup rotation script
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="${BACKUP_DIR:-$HOME/.paperclip/instances/default/data/backups}"
# Retention config (override via env)
RETENTION_HOURLY="${RETENTION_HOURLY:-24}"
RETENTION_DAILY_DAYS="${RETENTION_DAILY_DAYS:-14}"
RETENTION_WEEKLY_DAYS="${RETENTION_WEEKLY_DAYS:-28}"
RETENTION_MONTHLY="${RETENTION_MONTHLY:-1}" # 1=enabled, 0=disabled
DRY_RUN=false
usage() {
cat <<EOF
Backup rotation script
Usage:
$(basename "$0") [--dry-run] [--help]
Options:
--dry-run Show what would be deleted without deleting
--help Show this help
Environment variables:
BACKUP_DIR Backup directory
RETENTION_HOURLY Number of most recent backups to always keep (default: 24)
RETENTION_DAILY_DAYS Keep 1 per day for N days (default: 14)
RETENTION_WEEKLY_DAYS Keep 1 per week for N days (default: 28)
RETENTION_MONTHLY Keep 1 per month forever (1=on, 0=off)
Examples:
RETENTION_HOURLY=48 $(basename "$0")
RETENTION_DAILY_DAYS=7 RETENTION_WEEKLY_DAYS=21 $(basename "$0") --dry-run
EOF
}
# Parse args
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
--help|-h) usage; exit 0 ;;
*) echo "Unknown argument: $arg" >&2; usage; exit 1 ;;
esac
done
[[ -d "$BACKUP_DIR" ]] || { echo "Backup directory not found: $BACKUP_DIR" >&2; exit 1; }
get_mtime() {
if [[ "$(uname)" == "Darwin" ]]; then
stat -f %m "$1"
else
stat -c %Y "$1"
fi
}
fmt_date() {
if date -d "@$1" +"$2" >/dev/null 2>&1; then
date -d "@$1" +"$2"
else
date -r "$1" +"$2"
fi
}
shopt -s nullglob
raw_files=( "$BACKUP_DIR"/*.sql )
shopt -u nullglob
(( ${#raw_files[@]} )) || { echo "No backups found."; exit 0; }
# Sort newest first
mapfile -t files < <(
for f in "${raw_files[@]}"; do
printf '%s\t%s\n' "$(get_mtime "$f")" "$f"
done | sort -rn | cut -f2-
)
now=$(date +%s)
declare -A keep daily weekly monthly
kept=0
deleted=0
# Keep N most recent
for i in "${!files[@]}"; do
(( i < RETENTION_HOURLY )) && keep["${files[$i]}"]=1
done
for f in "${files[@]}"; do
mtime=$(get_mtime "$f")
age_days=$(( (now - mtime) / 86400 ))
day=$(fmt_date "$mtime" "%Y-%m-%d")
week=$(fmt_date "$mtime" "%Y-%W")
month=$(fmt_date "$mtime" "%Y-%m")
if (( age_days <= RETENTION_DAILY_DAYS )) && [[ -z "${daily[$day]:-}" ]]; then
daily["$day"]="$f"
fi
if (( age_days <= RETENTION_WEEKLY_DAYS )) && [[ -z "${weekly[$week]:-}" ]]; then
weekly["$week"]="$f"
fi
if (( RETENTION_MONTHLY == 1 )) && [[ -z "${monthly[$month]:-}" ]]; then
monthly["$month"]="$f"
fi
done
for k in "${!daily[@]}"; do keep["${daily[$k]}"]=1; done
for k in "${!weekly[@]}"; do keep["${weekly[$k]}"]=1; done
for k in "${!monthly[@]}"; do keep["${monthly[$k]}"]=1; done
for f in "${files[@]}"; do
if [[ -n "${keep[$f]:-}" ]]; then
((++kept))
else
if $DRY_RUN; then
echo "[dry-run] would delete: $(basename "$f")"
else
rm -- "$f"
echo "deleted: $(basename "$f")"
fi
((++deleted))
fi
done
suffix=""
$DRY_RUN && suffix=" (dry run)"
echo "$(date): kept=$kept deleted=$deleted total=${#files[@]}$suffix"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment