#!/bin/bash # ============================================================================= # git-sync.sh — Multi-machine git sync for Entire CLI projects # ============================================================================= # Usage: # bash .entire/git-sync.sh pull # SessionStart: fetch + rebase # bash .entire/git-sync.sh push # post-commit: push to remote (background) # bash .entire/git-sync.sh status # Diagnostics # bash .entire/git-sync.sh cleanup # Remove old backup tags # ============================================================================= set -uo pipefail SYNC_DIR=".entire/tmp" SYNC_LOG="$SYNC_DIR/sync.log" SYNC_LOCK="$SYNC_DIR/sync.lock" NETWORK_TIMEOUT=30 BACKUP_TAG_PREFIX="sync/backup" DAILY_TAG_PREFIX="sync/daily" BACKUP_MAX_AGE_DAYS=7 DAILY_MAX_AGE_DAYS=30 ARCHIVE_INTERVAL_DAYS=3 ARCHIVE_BRANCH="archive/snapshots" ARCHIVE_LOCAL_DIR=".entire/tmp/archives" STALE_LOCK_SECONDS=120 # --- Logging --- log() { mkdir -p "$SYNC_DIR" local ts ts=$(date '+%Y-%m-%d %H:%M:%S') echo "[$ts] $*" >> "$SYNC_LOG" } log_and_echo() { log "$@" echo "[git-sync] $*" } # --- Lock management --- acquire_lock() { mkdir -p "$SYNC_DIR" # Remove stale lock if [ -f "$SYNC_LOCK" ]; then local lock_pid lock_age lock_pid=$(cat "$SYNC_LOCK" 2>/dev/null || echo "") lock_age=$(( $(date +%s) - $(stat -c %Y "$SYNC_LOCK" 2>/dev/null || stat -f %m "$SYNC_LOCK" 2>/dev/null || echo 0) )) if [ -n "$lock_pid" ] && ! kill -0 "$lock_pid" 2>/dev/null; then log "Removing lock from dead process (PID: $lock_pid)" rm -f "$SYNC_LOCK" elif [ "$lock_age" -gt "$STALE_LOCK_SECONDS" ]; then log "Removing stale lock (age: ${lock_age}s)" rm -f "$SYNC_LOCK" else log "Lock held by process $lock_pid (age: ${lock_age}s), skipping" return 1 fi fi echo $$ > "$SYNC_LOCK" return 0 } release_lock() { rm -f "$SYNC_LOCK" } # --- Detect current branch --- current_branch() { git symbolic-ref --short HEAD 2>/dev/null } # --- Check if we should sync (only main/master) --- should_sync() { local branch branch=$(current_branch) if [ -z "$branch" ]; then log "Detached HEAD, skipping sync" return 1 fi case "$branch" in main|master) return 0 ;; *) log "Branch '$branch' is not main/master, skipping sync" return 1 ;; esac } # --- Detect remote for current branch --- detect_remote() { local branch branch=$(current_branch) local remote remote=$(git config "branch.${branch}.remote" 2>/dev/null) if [ -n "$remote" ]; then echo "$remote" return 0 fi # Fallback: try first available remote remote=$(git remote | head -1) if [ -n "$remote" ]; then echo "$remote" return 0 fi return 1 } # --- Create backup tag --- create_backup_tag() { local ts ts=$(date '+%Y%m%d-%H%M%S') local tag="${BACKUP_TAG_PREFIX}/pre-rebase/${ts}" git tag "$tag" HEAD 2>/dev/null && log "Backup tag: $tag" } # --- Daily snapshot tag with summary --- create_daily_tag() { local today today=$(date '+%Y-%m-%d') local tag="${DAILY_TAG_PREFIX}/${today}" # Skip if today's tag already exists if git tag -l "$tag" 2>/dev/null | grep -q .; then return 0 fi # Find yesterday's tag or the most recent daily tag local prev_ref="" prev_ref=$(git tag -l "${DAILY_TAG_PREFIX}/*" 2>/dev/null | sort -r | head -1) # Build summary of commits since last daily tag (or last 24h) local summary="" if [ -n "$prev_ref" ]; then summary=$(git log --oneline "${prev_ref}..HEAD" 2>/dev/null | head -20) else summary=$(git log --oneline --since="yesterday" 2>/dev/null | head -20) fi if [ -z "$summary" ]; then summary="(no new commits)" fi local commit_count if [ -n "$prev_ref" ]; then commit_count=$(git rev-list --count "${prev_ref}..HEAD" 2>/dev/null || echo 0) else commit_count=$(git log --oneline --since="yesterday" 2>/dev/null | wc -l) fi # Create annotated tag with summary as message local msg msg=$(printf "Daily snapshot %s (%s commits)\n\n%s" "$today" "$commit_count" "$summary") git tag -a "$tag" -m "$msg" HEAD 2>/dev/null && log "Daily tag: $tag ($commit_count commits)" } # --- Periodic archive: full snapshot to archive/snapshots branch --- create_periodic_archive() { local today today=$(date '+%Y-%m-%d') local branch branch=$(current_branch) local project_name project_name=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)") # Check if archive branch exists, get last archive date local last_archive_date="0000-00-00" if git rev-parse --verify "$ARCHIVE_BRANCH" &>/dev/null; then # Get date from last commit message on archive branch last_archive_date=$(git log "$ARCHIVE_BRANCH" -1 --format="%s" 2>/dev/null | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}' | head -1 || echo "0000-00-00") fi # Check interval: skip if last archive is less than ARCHIVE_INTERVAL_DAYS ago if [ "$last_archive_date" != "0000-00-00" ]; then local last_epoch today_epoch last_epoch=$(date -d "$last_archive_date" +%s 2>/dev/null || date -j -f '%Y-%m-%d' "$last_archive_date" +%s 2>/dev/null || echo 0) today_epoch=$(date +%s) local diff_days=$(( (today_epoch - last_epoch) / 86400 )) if [ "$diff_days" -lt "$ARCHIVE_INTERVAL_DAYS" ]; then log "Archive skipped: last archive $last_archive_date ($diff_days days ago, interval=${ARCHIVE_INTERVAL_DAYS})" return 0 fi fi # Check if there are new commits since last archive local new_commits=0 if git rev-parse --verify "$ARCHIVE_BRANCH" &>/dev/null; then # Find the source commit hash stored in last archive local last_source_hash last_source_hash=$(git log "$ARCHIVE_BRANCH" -1 --format="%b" 2>/dev/null | sed -n 's/^Source: \([a-f0-9]*\).*/\1/p' | head -1) if [ -n "$last_source_hash" ] && git rev-parse --verify "$last_source_hash" &>/dev/null; then new_commits=$(git rev-list --count "${last_source_hash}..HEAD" 2>/dev/null || echo 0) else new_commits=1 # Can't determine, assume changes fi else new_commits=$(git rev-list --count HEAD 2>/dev/null || echo 1) fi if [ "$new_commits" -eq 0 ]; then log "Archive skipped: 0 new commits since last archive" return 0 fi log_and_echo "Creating archive ($new_commits new commits since last snapshot)..." local short_hash short_hash=$(git rev-parse --short HEAD 2>/dev/null) local archive_name="${project_name}_${today}_${short_hash}.tar.gz" # Create archive in temp local tmpdir tmpdir=$(mktemp -d) if ! git archive --format=tar.gz --prefix="${project_name}/" -o "${tmpdir}/${archive_name}" HEAD 2>>"$SYNC_LOG"; then log "Archive creation failed" rm -rf "$tmpdir" return 1 fi local size size=$(du -h "${tmpdir}/${archive_name}" | cut -f1) # Build changelog local changelog="${tmpdir}/CHANGELOG.md" local prev_tag prev_tag=$(git tag -l "${DAILY_TAG_PREFIX}/*" 2>/dev/null | sort -r | head -1) { echo "# Archive ${today} (${branch}@${short_hash})" echo "" echo "**Project**: ${project_name}" echo "**Branch**: ${branch}" echo "**Commit**: ${short_hash} ($(git log -1 --format='%s' HEAD 2>/dev/null))" echo "**Size**: ${size}" echo "**Date**: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" echo "" echo "## Commits since last archive" echo "" if git rev-parse --verify "$ARCHIVE_BRANCH" &>/dev/null; then local last_src last_src=$(git log "$ARCHIVE_BRANCH" -1 --format="%b" 2>/dev/null | sed -n 's/^Source: \([a-f0-9]*\).*/\1/p' | head -1) if [ -n "$last_src" ] && git rev-parse --verify "$last_src" &>/dev/null; then git log --oneline "${last_src}..HEAD" 2>/dev/null | while read -r line; do echo "- ${line}" done else git log --oneline -20 HEAD 2>/dev/null | while read -r line; do echo "- ${line}" done fi else git log --oneline -50 HEAD 2>/dev/null | while read -r line; do echo "- ${line}" done fi } > "$changelog" # Save local copy mkdir -p "$ARCHIVE_LOCAL_DIR" cp "${tmpdir}/${archive_name}" "$ARCHIVE_LOCAL_DIR/" cp "${tmpdir}/CHANGELOG.md" "${ARCHIVE_LOCAL_DIR}/CHANGELOG_${today}.md" log "Local archive copy: ${ARCHIVE_LOCAL_DIR}/${archive_name}" # Cleanup local archives older than 30 days local cutoff cutoff=$(date -d "-30 days" '+%Y-%m-%d' 2>/dev/null || \ date -v "-30d" '+%Y-%m-%d' 2>/dev/null || echo "0000-00-00") for f in "${ARCHIVE_LOCAL_DIR}/${project_name}_"*.tar.gz; do [ -f "$f" ] || continue local fdate fdate=$(echo "$f" | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}' | tail -1) if [ -n "$fdate" ] && [ "$fdate" \< "$cutoff" ]; then rm -f "$f" # Also remove matching changelog rm -f "${ARCHIVE_LOCAL_DIR}/CHANGELOG_${fdate}.md" fi done # Switch to archive branch (orphan if new), commit, switch back local original_branch original_branch=$(current_branch) local archive_stashed=false # Safety trap: restore branch on interrupt archive_cleanup() { if [ "$(current_branch 2>/dev/null)" != "$original_branch" ]; then git checkout "$original_branch" 2>/dev/null || true fi if [ "$archive_stashed" = true ]; then git stash pop 2>/dev/null || true fi } trap archive_cleanup INT TERM if ! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null; then git stash push -m "archive-stash" 2>/dev/null && archive_stashed=true fi if git rev-parse --verify "$ARCHIVE_BRANCH" &>/dev/null; then git checkout "$ARCHIVE_BRANCH" 2>>"$SYNC_LOG" else git checkout --orphan "$ARCHIVE_BRANCH" 2>>"$SYNC_LOG" git rm -rf . 2>/dev/null || true fi # Copy archive and changelog cp "${tmpdir}/${archive_name}" . cp "${tmpdir}/CHANGELOG.md" . git add "${archive_name}" CHANGELOG.md 2>/dev/null # Commit with source hash in body for tracking local full_hash full_hash=$(git rev-parse "$original_branch" 2>/dev/null || echo "$short_hash") git commit -m "snapshot ${today} (${branch}@${short_hash}, ${size})" \ -m "Source: ${full_hash}" \ 2>>"$SYNC_LOG" # Push archive branch local remote remote=$(detect_remote) || true if [ -n "$remote" ]; then timeout "$NETWORK_TIMEOUT" git push "$remote" "$ARCHIVE_BRANCH" 2>>"$SYNC_LOG" || \ log "Archive branch push failed" fi # Switch back and remove trap git checkout "$original_branch" 2>>"$SYNC_LOG" if [ "$archive_stashed" = true ]; then git stash pop 2>/dev/null || true fi trap - INT TERM rm -rf "$tmpdir" log_and_echo "Archive committed to ${ARCHIVE_BRANCH}: ${archive_name} (${size}, ${new_commits} commits)" } # --- Push tags to remote --- push_tags() { local remote remote=$(detect_remote) || return 0 # Push only sync/* tags (not all tags) local tag while IFS= read -r tag; do [ -z "$tag" ] && continue timeout "$NETWORK_TIMEOUT" git push "$remote" "refs/tags/$tag" 2>>"$SYNC_LOG" || \ log "Failed to push tag: $tag" done < <(git tag -l "sync/*" 2>/dev/null) log "Sync tags pushed to $remote" } # --- PULL: fetch + rebase --- do_pull() { if ! should_sync; then echo "[git-sync] Skipped (not on main/master)" return 0 fi local remote remote=$(detect_remote) || { echo "[git-sync] No remote configured, skipping pull" return 0 } local branch branch=$(current_branch) if ! acquire_lock; then echo "[git-sync] Another sync in progress, skipping" return 0 fi trap release_lock EXIT # Fetch with timeout log "Fetching from $remote..." if ! timeout "$NETWORK_TIMEOUT" git fetch "$remote" "$branch" 2>>"$SYNC_LOG"; then log_and_echo "Fetch failed (network timeout or error), continuing with local state" return 0 fi # Create daily snapshot tag and periodic archive after fetch (has latest remote state) create_daily_tag create_periodic_archive # Check if we're behind local behind behind=$(git rev-list --count "HEAD..${remote}/${branch}" 2>/dev/null || echo 0) if [ "$behind" -eq 0 ]; then log_and_echo "Already up to date with ${remote}/${branch}" return 0 fi log_and_echo "Behind ${remote}/${branch} by $behind commit(s), syncing..." # Create backup tag before rebase create_backup_tag # Stash dirty changes if any local stashed=false if ! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null; then log "Stashing dirty working tree..." git stash push -m "git-sync auto-stash $(date '+%Y%m%d-%H%M%S')" 2>>"$SYNC_LOG" && stashed=true fi # Rebase if git rebase "${remote}/${branch}" 2>>"$SYNC_LOG"; then log_and_echo "Rebased successfully onto ${remote}/${branch} (+$behind commits)" else log_and_echo "WARNING: Rebase conflict! Aborting rebase, backup tag preserved" git rebase --abort 2>/dev/null if [ "$stashed" = true ]; then git stash pop 2>>"$SYNC_LOG" || true fi return 1 fi # Restore stashed changes if [ "$stashed" = true ]; then if git stash pop 2>>"$SYNC_LOG"; then log "Stash restored successfully" else log_and_echo "WARNING: Stash pop conflict! Your changes are saved in git stash" fi fi return 0 } # --- PUSH: push to remote --- do_push() { if ! should_sync; then return 0 fi local remote remote=$(detect_remote) || return 0 local branch branch=$(current_branch) if ! acquire_lock; then return 0 fi trap release_lock EXIT log "Pushing to ${remote}/${branch}..." if timeout "$NETWORK_TIMEOUT" git push "$remote" "$branch" 2>>"$SYNC_LOG"; then log "Push successful" # Push tags to remote after successful push push_tags else log "Push failed (likely behind remote). Will reconcile at next session pull." fi } # --- STATUS: diagnostics --- do_status() { local branch branch=$(current_branch) echo "[git-sync] Status" echo " Branch: ${branch:-DETACHED}" if ! should_sync; then echo " Sync: DISABLED (not on main/master)" return 0 fi local remote remote=$(detect_remote) || { echo " Remote: NONE" echo " Sync: DISABLED (no remote)" return 0 } echo " Remote: $remote" # Fetch silently for status check timeout "$NETWORK_TIMEOUT" git fetch "$remote" "$branch" 2>/dev/null local ahead behind ahead=$(git rev-list --count "${remote}/${branch}..HEAD" 2>/dev/null || echo "?") behind=$(git rev-list --count "HEAD..${remote}/${branch}" 2>/dev/null || echo "?") echo " Ahead: $ahead | Behind: $behind" local backup_count daily_count backup_count=$(git tag -l "${BACKUP_TAG_PREFIX}/*" 2>/dev/null | wc -l) daily_count=$(git tag -l "${DAILY_TAG_PREFIX}/*" 2>/dev/null | wc -l) echo " Backup tags: $backup_count" echo " Daily snapshots: $daily_count" # Show recent daily tags with summaries local latest_daily latest_daily=$(git tag -l "${DAILY_TAG_PREFIX}/*" 2>/dev/null | sort -r | head -3) if [ -n "$latest_daily" ]; then echo " Recent daily snapshots:" echo "$latest_daily" | while read -r dtag; do local dmsg dmsg=$(git tag -n1 "$dtag" 2>/dev/null | sed "s|^${dtag}\s*||") echo " $dtag — $dmsg" done fi # Show archive branch info if git rev-parse --verify "$ARCHIVE_BRANCH" &>/dev/null; then local archive_count archive_count=$(git log "$ARCHIVE_BRANCH" --oneline 2>/dev/null | wc -l) echo " Archive branch: $ARCHIVE_BRANCH ($archive_count snapshots)" echo " Recent archives:" git log "$ARCHIVE_BRANCH" --oneline -3 2>/dev/null | sed 's/^/ /' else echo " Archive branch: not created yet (interval: every ${ARCHIVE_INTERVAL_DAYS} days)" fi if [ -f "$SYNC_LOG" ]; then echo " Last log entries:" tail -5 "$SYNC_LOG" | sed 's/^/ /' fi echo " Sync: ENABLED" } # --- CLEANUP: remove old backup tags --- do_cleanup() { local cutoff cutoff=$(date -d "-${BACKUP_MAX_AGE_DAYS} days" '+%Y%m%d' 2>/dev/null || \ date -v "-${BACKUP_MAX_AGE_DAYS}d" '+%Y%m%d' 2>/dev/null || echo "00000000") local count=0 while IFS= read -r tag; do [ -z "$tag" ] && continue # Extract date from tag: sync/backup/pre-rebase/YYYYMMDD-HHMMSS local tag_date tag_date=$(echo "$tag" | grep -oE '[0-9]{8}' | head -1) if [ -n "$tag_date" ] && [ "$tag_date" -lt "$cutoff" ] 2>/dev/null; then git tag -d "$tag" 2>/dev/null count=$((count + 1)) fi done < <(git tag -l "${BACKUP_TAG_PREFIX}/*" 2>/dev/null) # Clean old daily tags (older than DAILY_MAX_AGE_DAYS) local daily_cutoff daily_cutoff=$(date -d "-${DAILY_MAX_AGE_DAYS} days" '+%Y-%m-%d' 2>/dev/null || \ date -v "-${DAILY_MAX_AGE_DAYS}d" '+%Y-%m-%d' 2>/dev/null || echo "0000-00-00") while IFS= read -r tag; do [ -z "$tag" ] && continue local tag_date tag_date=$(echo "$tag" | sed "s|${DAILY_TAG_PREFIX}/||") if [ "$tag_date" \< "$daily_cutoff" ] 2>/dev/null; then git tag -d "$tag" 2>/dev/null count=$((count + 1)) fi done < <(git tag -l "${DAILY_TAG_PREFIX}/*" 2>/dev/null) log_and_echo "Cleaned up $count old backup/daily tag(s)" # Trim sync log if > 1000 lines if [ -f "$SYNC_LOG" ]; then local lines lines=$(wc -l < "$SYNC_LOG") if [ "$lines" -gt 1000 ]; then tail -500 "$SYNC_LOG" > "${SYNC_LOG}.tmp" && mv "${SYNC_LOG}.tmp" "$SYNC_LOG" log "Trimmed sync log from $lines to 500 lines" fi fi } # --- Main --- case "${1:-}" in pull) do_pull ;; push) do_push ;; status) do_status ;; cleanup) do_cleanup ;; *) echo "Usage: bash .entire/git-sync.sh {pull|push|status|cleanup}" exit 1 ;; esac