synq-core-os/scripts/synq-auto-backup.sh
Synq Imaging 81e9381957 [autosave] milestone/2.1-stream-ui @ 2026-05-08T09:39:04-07:00
1 files, +7/-1 lines, 0
0 new

 M scripts/synq-auto-backup.sh
2026-05-08 09:39:04 -07:00

174 lines
7.7 KiB
Bash
Executable file

#!/bin/bash
# ═══════════════════════════════════════════════════════════════
# Synq Auto-Backup v2
# Canonical script — lives in synq-core-runtime/scripts/
#
# Usage:
# synq-auto-backup.sh [project-dir]
#
# Features:
# • Detailed autosave commits with file-change summaries
# • Lightweight tags for precise rollback points
# • Respects staged changes (skips auto-save if developer has git-added)
# • Prunes old autosave tags (keeps last 30 per branch)
# • JSONL structured logging
# ═══════════════════════════════════════════════════════════════
set -euo pipefail
PROJECT_DIR="${1:-$(pwd)}"
cd "$PROJECT_DIR"
# ── Config ────────────────────────────────────────────────────
AUTOSAVE_PREFIX="[autosave]"
TAG_PREFIX="autosave"
MAX_TAGS_PER_BRANCH=30
LOG_DIR="${HOME}/.local/share/synq"
LOG_FILE="$LOG_DIR/backup-log.jsonl"
FORGEJO_REMOTE="forgejo"
FORGEJO_LOCAL="forgejo-local"
# ── Helpers ───────────────────────────────────────────────────
log_json() {
local event="$1"
local project="$2"
local branch="$3"
local details="${4:-{}}"
mkdir -p "$LOG_DIR"
printf '{"t":"%s","e":"%s","p":"%s","b":"%s","d":%s}\n' \
"$(date -Iseconds)" "$event" "$project" "$branch" "$details" >> "$LOG_FILE"
}
sanitize_branch() {
# Replace slashes with dashes for tag safety
echo "$1" | tr '/' '-'
}
# ── Validate git repo ─────────────────────────────────────────
if ! git rev-parse --git-dir > /dev/null 2>&1; then
echo "⚠ Not a git repository: $PROJECT_DIR"
log_json "skip_not_git" "$(basename "$PROJECT_DIR")" "unknown" '{"reason":"not_a_git_repo"}'
exit 0
fi
# ── Warn about missing .gitignore for common dirs ─────────────
for risky_dir in node_modules target dist build .cargo; do
if [ -d "$risky_dir" ] && [ ! -f .gitignore ]; then
echo "⚠ WARNING: $PROJECT_NAME has '$risky_dir' but no .gitignore — autosave will skip this project"
log_json "skip_no_gitignore" "$PROJECT_NAME" "$(git branch --show-current 2>/dev/null || echo main)" \
"{\"dir\":\"$risky_dir\"}"
exit 0
fi
done
BRANCH=$(git branch --show-current 2>/dev/null || echo "main")
PROJECT_NAME=$(basename "$(git rev-parse --show-toplevel)")
SAFE_BRANCH=$(sanitize_branch "$BRANCH")
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
ISOTIME=$(date -Iseconds)
# ── Skip if no changes ────────────────────────────────────────
# Use --porcelain to catch untracked files too
STATUS_PORCELAIN=$(git status --porcelain 2>/dev/null || true)
if [ -z "$STATUS_PORCELAIN" ]; then
echo "$PROJECT_NAME ($BRANCH): Working tree clean — nothing to save"
log_json "skip_clean" "$PROJECT_NAME" "$BRANCH" '{}'
exit 0
fi
# ── Respect developer intent ──────────────────────────────────
# If the developer has already staged some changes manually,
# we skip the auto-save so we don't interfere with their workflow.
if ! git diff --cached --quiet; then
echo "$PROJECT_NAME ($BRANCH): Staged changes detected — skipping autosave (developer is mid-commit)"
log_json "skip_staged" "$PROJECT_NAME" "$BRANCH" '{}'
exit 0
fi
# ── Build file-change summary ─────────────────────────────────
# Use porcelain status for complete picture (untracked + modified + staged)
FILE_LIST=$(echo "$STATUS_PORCELAIN" | head -20 || true)
FILE_COUNT=$(echo "$STATUS_PORCELAIN" | grep -c '^.' 2>/dev/null || echo "0")
# Get diff stats for tracked file changes only
SHORTSTAT=$(git diff --shortstat 2>/dev/null || true)
INSERTIONS=$(echo "$SHORTSTAT" | grep -oP '\d+(?= insertion)' || echo "0")
DELETIONS=$(echo "$SHORTSTAT" | grep -oP '\d+(?= deletion)' || echo "0")
# Count untracked files separately
UNTRACKED_COUNT=$(echo "$STATUS_PORCELAIN" | grep -c '^??' 2>/dev/null || echo "0")
# ── Commit message ────────────────────────────────────────────
# Build a nice summary line
SUMMARY_LINE="${FILE_COUNT} files"
if [ "$INSERTIONS" != "0" ] || [ "$DELETIONS" != "0" ]; then
SUMMARY_LINE="${SUMMARY_LINE}, +${INSERTIONS}/-${DELETIONS} lines"
fi
if [ "$UNTRACKED_COUNT" != "0" ]; then
SUMMARY_LINE="${SUMMARY_LINE}, ${UNTRACKED_COUNT} new"
fi
COMMIT_MSG="${AUTOSAVE_PREFIX} ${BRANCH} @ ${ISOTIME}
${SUMMARY_LINE}
${FILE_LIST}"
# ── Stage & commit ────────────────────────────────────────────
git add -A
COMMIT_HASH=$(git commit -m "$COMMIT_MSG" --quiet && git rev-parse HEAD)
# ── Tag the autosave ──────────────────────────────────────────
TAG_NAME="${TAG_PREFIX}/${SAFE_BRANCH}/${TIMESTAMP}"
git tag "$TAG_NAME" "$COMMIT_HASH"
# ── Push branch + tag to Forgejo ──────────────────────────────
PUSHED_BRANCH=false
PUSHED_TAG=false
if git remote get-url "$FORGEJO_REMOTE" > /dev/null 2>&1; then
if git push "$FORGEJO_REMOTE" "$BRANCH" --quiet 2>/dev/null; then
PUSHED_BRANCH=true
echo "$PROJECT_NAME ($BRANCH): Pushed to Forgejo"
else
# Normal push failed — try force-with-lease (safe for autosave branches)
if git push "$FORGEJO_REMOTE" "$BRANCH" --force-with-lease --quiet 2>/dev/null; then
PUSHED_BRANCH=true
echo "$PROJECT_NAME ($BRANCH): Force-pushed to Forgejo (local was behind)"
else
echo "$PROJECT_NAME ($BRANCH): Branch push to Forgejo failed"
fi
fi
if git push "$FORGEJO_REMOTE" "$TAG_NAME" --quiet 2>/dev/null; then
PUSHED_TAG=true
echo "$PROJECT_NAME ($BRANCH): Tagged $TAG_NAME on Forgejo"
else
echo "$PROJECT_NAME ($BRANCH): Tag push to Forgejo failed"
fi
else
echo "$PROJECT_NAME: No '$FORGEJO_REMOTE' remote configured"
fi
# Try local Forgejo too (best-effort)
if git remote get-url "$FORGEJO_LOCAL" > /dev/null 2>&1; then
git push "$FORGEJO_LOCAL" "$BRANCH" --quiet 2>/dev/null || true
git push "$FORGEJO_LOCAL" "$TAG_NAME" --quiet 2>/dev/null || true
fi
# ── Prune old autosave tags ───────────────────────────────────
# Keep only the last MAX_TAGS_PER_BRANCH autosave tags for this branch
OLD_TAGS=$(git tag -l "${TAG_PREFIX}/${SAFE_BRANCH}/*" --sort=creatordate | head -n -${MAX_TAGS_PER_BRANCH})
if [ -n "$OLD_TAGS" ]; then
echo "$OLD_TAGS" | xargs -r git tag -d > /dev/null 2>&1 || true
if [ "$PUSHED_TAG" = true ]; then
# Also delete from remote Forgejo
echo "$OLD_TAGS" | xargs -r git push "$FORGEJO_REMOTE" --delete --quiet 2>/dev/null || true
fi
fi
# ── Log ───────────────────────────────────────────────────────
log_json "autosave" "$PROJECT_NAME" "$BRANCH" \
"{\"hash\":\"${COMMIT_HASH:0:8}\",\"tag\":\"$TAG_NAME\",\"files\":$FILE_COUNT,\"ins\":$INSERTIONS,\"del\":$DELETIONS,\"pushed_branch\":$PUSHED_BRANCH,\"pushed_tag\":$PUSHED_TAG}"
echo "$PROJECT_NAME ($BRANCH): Autosave $TAG_NAME (${COMMIT_HASH:0:8}) complete"