174 lines
7.7 KiB
Bash
Executable file
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"
|