458 lines
13 KiB
Bash
Executable File
458 lines
13 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# ============================================================================
|
|
# Dotfiles Sync - Auto-sync across machines
|
|
# ============================================================================
|
|
# Keeps your dotfiles synchronized across multiple machines
|
|
#
|
|
# Usage:
|
|
# dotfiles-sync.sh # Interactive sync
|
|
# dotfiles-sync.sh --push # Push local changes
|
|
# dotfiles-sync.sh --pull # Pull remote changes
|
|
# dotfiles-sync.sh --status # Show sync status
|
|
# dotfiles-sync.sh --watch # Watch for changes (daemon mode)
|
|
# dotfiles-sync.sh --auto # Auto-sync on shell start
|
|
# ============================================================================
|
|
|
|
set -e
|
|
|
|
# ============================================================================
|
|
# Load Configuration
|
|
# ============================================================================
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
DOTFILES_CONF="$HOME/.dotfiles/dotfiles.conf"
|
|
|
|
if [[ -f "$DOTFILES_CONF" ]]; then
|
|
source "$DOTFILES_CONF"
|
|
else
|
|
DOTFILES_DIR="$HOME/.dotfiles"
|
|
DOTFILES_BRANCH="main"
|
|
fi
|
|
|
|
SYNC_STATE_FILE="$DOTFILES_DIR/.sync_state"
|
|
SYNC_LOG_FILE="$DOTFILES_DIR/.sync_log"
|
|
HOSTNAME=$(hostname -s)
|
|
|
|
# ============================================================================
|
|
# Colors
|
|
# ============================================================================
|
|
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[1;33m'
|
|
BLUE='\033[0;34m'
|
|
CYAN='\033[0;36m'
|
|
DIM='\033[2m'
|
|
NC='\033[0m'
|
|
|
|
# ============================================================================
|
|
# Helper Functions
|
|
# ============================================================================
|
|
|
|
print_header() {
|
|
echo -e "\n${BLUE}╔════════════════════════════════════════════════════════════╗${NC}"
|
|
echo -e "${BLUE}║${NC} Dotfiles Sync ${BLUE}║${NC}"
|
|
echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}\n"
|
|
}
|
|
|
|
log_sync() {
|
|
local action="$1"
|
|
local details="$2"
|
|
echo "$(date -Iseconds) | $HOSTNAME | $action | $details" >> "$SYNC_LOG_FILE"
|
|
}
|
|
|
|
get_local_status() {
|
|
cd "$DOTFILES_DIR"
|
|
|
|
local status=""
|
|
local ahead=0
|
|
local behind=0
|
|
local modified=0
|
|
local untracked=0
|
|
|
|
# Fetch quietly
|
|
git fetch origin --quiet 2>/dev/null || true
|
|
|
|
# Count commits ahead/behind
|
|
ahead=$(git rev-list HEAD..origin/${DOTFILES_BRANCH} --count 2>/dev/null || echo 0)
|
|
behind=$(git rev-list origin/${DOTFILES_BRANCH}..HEAD --count 2>/dev/null || echo 0)
|
|
|
|
# Count modified and untracked
|
|
modified=$(git diff --name-only 2>/dev/null | wc -l | tr -d ' ')
|
|
untracked=$(git ls-files --others --exclude-standard 2>/dev/null | wc -l | tr -d ' ')
|
|
|
|
echo "$ahead|$behind|$modified|$untracked"
|
|
}
|
|
|
|
has_local_changes() {
|
|
cd "$DOTFILES_DIR"
|
|
! git diff --quiet 2>/dev/null || [[ -n $(git ls-files --others --exclude-standard) ]]
|
|
}
|
|
|
|
has_remote_changes() {
|
|
cd "$DOTFILES_DIR"
|
|
git fetch origin --quiet 2>/dev/null || true
|
|
local behind=$(git rev-list HEAD..origin/${DOTFILES_BRANCH} --count 2>/dev/null || echo 0)
|
|
[[ $behind -gt 0 ]]
|
|
}
|
|
|
|
# ============================================================================
|
|
# Sync Functions
|
|
# ============================================================================
|
|
|
|
show_status() {
|
|
print_header
|
|
|
|
echo -e "${CYAN}Machine:${NC} $HOSTNAME"
|
|
echo -e "${CYAN}Branch:${NC} $DOTFILES_BRANCH"
|
|
echo -e "${CYAN}Path:${NC} $DOTFILES_DIR"
|
|
echo
|
|
|
|
cd "$DOTFILES_DIR"
|
|
|
|
IFS='|' read -r behind ahead modified untracked <<< "$(get_local_status)"
|
|
|
|
echo -e "${CYAN}Status:${NC}"
|
|
|
|
# Remote status
|
|
if [[ $behind -gt 0 ]]; then
|
|
echo -e " ${YELLOW}↓${NC} $behind commit(s) behind remote"
|
|
elif [[ $ahead -gt 0 ]]; then
|
|
echo -e " ${GREEN}↑${NC} $ahead commit(s) ahead of remote"
|
|
else
|
|
echo -e " ${GREEN}✓${NC} In sync with remote"
|
|
fi
|
|
|
|
# Local changes
|
|
if [[ $modified -gt 0 ]]; then
|
|
echo -e " ${YELLOW}●${NC} $modified modified file(s)"
|
|
fi
|
|
|
|
if [[ $untracked -gt 0 ]]; then
|
|
echo -e " ${YELLOW}+${NC} $untracked untracked file(s)"
|
|
fi
|
|
|
|
if [[ $modified -eq 0 && $untracked -eq 0 ]]; then
|
|
echo -e " ${GREEN}✓${NC} Working directory clean"
|
|
fi
|
|
|
|
# Show recent changes
|
|
echo
|
|
echo -e "${CYAN}Recent changes:${NC}"
|
|
git log --oneline -5 2>/dev/null | while read -r line; do
|
|
echo -e " ${DIM}$line${NC}"
|
|
done
|
|
|
|
# Show modified files
|
|
if [[ $modified -gt 0 || $untracked -gt 0 ]]; then
|
|
echo
|
|
echo -e "${CYAN}Changed files:${NC}"
|
|
git status --short 2>/dev/null | head -10 | while read -r line; do
|
|
echo -e " $line"
|
|
done
|
|
local total=$((modified + untracked))
|
|
[[ $total -gt 10 ]] && echo -e " ${DIM}... and $((total - 10)) more${NC}"
|
|
fi
|
|
|
|
# Show last sync
|
|
if [[ -f "$SYNC_STATE_FILE" ]]; then
|
|
echo
|
|
local last_sync=$(cat "$SYNC_STATE_FILE")
|
|
echo -e "${CYAN}Last sync:${NC} $last_sync"
|
|
fi
|
|
}
|
|
|
|
do_push() {
|
|
local message="${1:-Auto-sync from $HOSTNAME}"
|
|
|
|
cd "$DOTFILES_DIR"
|
|
|
|
if ! has_local_changes; then
|
|
echo -e "${GREEN}✓${NC} No local changes to push"
|
|
return 0
|
|
fi
|
|
|
|
echo -e "${BLUE}==>${NC} Pushing local changes..."
|
|
|
|
# Stage all changes
|
|
git add -A
|
|
|
|
# Show what we're committing
|
|
echo -e "${CYAN}Changes:${NC}"
|
|
git diff --cached --stat | head -10
|
|
|
|
echo
|
|
|
|
# Commit
|
|
git commit -m "$message" || {
|
|
echo -e "${YELLOW}⚠${NC} Nothing to commit"
|
|
return 0
|
|
}
|
|
|
|
# Push
|
|
if git push origin "$DOTFILES_BRANCH"; then
|
|
echo -e "${GREEN}✓${NC} Changes pushed successfully"
|
|
log_sync "push" "$message"
|
|
date -Iseconds > "$SYNC_STATE_FILE"
|
|
else
|
|
echo -e "${RED}✗${NC} Failed to push changes"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
do_pull() {
|
|
cd "$DOTFILES_DIR"
|
|
|
|
echo -e "${BLUE}==>${NC} Pulling remote changes..."
|
|
|
|
# Stash local changes if any
|
|
local had_changes=false
|
|
if has_local_changes; then
|
|
echo -e "${YELLOW}⚠${NC} Stashing local changes..."
|
|
git stash push -m "Auto-stash before pull"
|
|
had_changes=true
|
|
fi
|
|
|
|
# Pull
|
|
if git pull origin "$DOTFILES_BRANCH"; then
|
|
echo -e "${GREEN}✓${NC} Changes pulled successfully"
|
|
log_sync "pull" "from origin/$DOTFILES_BRANCH"
|
|
date -Iseconds > "$SYNC_STATE_FILE"
|
|
|
|
# Show what changed
|
|
echo -e "${CYAN}Updates:${NC}"
|
|
git log --oneline ORIG_HEAD..HEAD 2>/dev/null | while read -r line; do
|
|
echo -e " ${GREEN}+${NC} $line"
|
|
done
|
|
else
|
|
echo -e "${RED}✗${NC} Failed to pull changes"
|
|
|
|
# Restore stash on failure
|
|
if [[ "$had_changes" == true ]]; then
|
|
git stash pop
|
|
fi
|
|
return 1
|
|
fi
|
|
|
|
# Restore stash
|
|
if [[ "$had_changes" == true ]]; then
|
|
echo -e "${BLUE}==>${NC} Restoring local changes..."
|
|
if git stash pop; then
|
|
echo -e "${GREEN}✓${NC} Local changes restored"
|
|
else
|
|
echo -e "${YELLOW}⚠${NC} Conflict restoring local changes"
|
|
echo " Resolve conflicts and run: git stash drop"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
do_sync() {
|
|
print_header
|
|
|
|
cd "$DOTFILES_DIR"
|
|
|
|
local has_local=$(has_local_changes && echo "yes" || echo "no")
|
|
local has_remote=$(has_remote_changes && echo "yes" || echo "no")
|
|
|
|
if [[ "$has_local" == "no" && "$has_remote" == "no" ]]; then
|
|
echo -e "${GREEN}✓${NC} Everything is in sync!"
|
|
return 0
|
|
fi
|
|
|
|
if [[ "$has_remote" == "yes" ]]; then
|
|
echo -e "${CYAN}Remote changes available${NC}"
|
|
do_pull
|
|
echo
|
|
fi
|
|
|
|
if [[ "$has_local" == "yes" ]]; then
|
|
echo -e "${CYAN}Local changes detected${NC}"
|
|
|
|
# Show changes
|
|
git status --short
|
|
echo
|
|
|
|
read -p "Push these changes? [Y/n]: " confirm
|
|
if [[ "${confirm:-y}" =~ ^[Yy] ]]; then
|
|
read -p "Commit message [Auto-sync from $HOSTNAME]: " msg
|
|
do_push "${msg:-Auto-sync from $HOSTNAME}"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
do_watch() {
|
|
echo -e "${BLUE}==>${NC} Starting sync daemon..."
|
|
echo -e "${DIM}Press Ctrl+C to stop${NC}"
|
|
echo
|
|
|
|
local interval="${1:-300}" # Default 5 minutes
|
|
|
|
log_sync "watch_start" "interval=${interval}s"
|
|
|
|
while true; do
|
|
local timestamp=$(date '+%H:%M:%S')
|
|
|
|
if has_remote_changes; then
|
|
echo -e "[$timestamp] ${YELLOW}↓${NC} Remote changes detected, pulling..."
|
|
do_pull
|
|
fi
|
|
|
|
if has_local_changes; then
|
|
echo -e "[$timestamp] ${YELLOW}↑${NC} Local changes detected"
|
|
# In watch mode, auto-commit with timestamp
|
|
do_push "Auto-sync: $(date '+%Y-%m-%d %H:%M') from $HOSTNAME"
|
|
fi
|
|
|
|
echo -e "[$timestamp] ${DIM}Sleeping ${interval}s...${NC}"
|
|
sleep "$interval"
|
|
done
|
|
}
|
|
|
|
do_auto() {
|
|
# Quick check for shell startup - minimal output
|
|
cd "$DOTFILES_DIR" 2>/dev/null || return 0
|
|
|
|
git fetch origin --quiet 2>/dev/null || return 0
|
|
|
|
local behind=$(git rev-list HEAD..origin/${DOTFILES_BRANCH} --count 2>/dev/null || echo 0)
|
|
|
|
if [[ $behind -gt 0 ]]; then
|
|
echo -e "${YELLOW}⚠ Dotfiles: $behind update(s) available${NC}"
|
|
echo -e " Run: ${CYAN}dfpull${NC} or ${CYAN}dotfiles-sync.sh --pull${NC}"
|
|
fi
|
|
|
|
if has_local_changes; then
|
|
local changed=$(git status --short 2>/dev/null | wc -l | tr -d ' ')
|
|
echo -e "${YELLOW}⚠ Dotfiles: $changed local change(s) not pushed${NC}"
|
|
echo -e " Run: ${CYAN}dfpush${NC} or ${CYAN}dotfiles-sync.sh --push${NC}"
|
|
fi
|
|
}
|
|
|
|
show_diff() {
|
|
cd "$DOTFILES_DIR"
|
|
|
|
echo -e "${CYAN}Local changes:${NC}"
|
|
echo
|
|
|
|
if command -v delta &>/dev/null; then
|
|
git diff | delta
|
|
elif command -v diff-so-fancy &>/dev/null; then
|
|
git diff | diff-so-fancy
|
|
else
|
|
git diff --color
|
|
fi
|
|
}
|
|
|
|
show_log() {
|
|
local count="${1:-20}"
|
|
|
|
if [[ -f "$SYNC_LOG_FILE" ]]; then
|
|
echo -e "${CYAN}Sync history (last $count entries):${NC}"
|
|
echo
|
|
tail -n "$count" "$SYNC_LOG_FILE" | while IFS='|' read -r timestamp host action details; do
|
|
echo -e "${DIM}$timestamp${NC} ${CYAN}$host${NC} ${GREEN}$action${NC} $details"
|
|
done
|
|
else
|
|
echo "No sync history yet."
|
|
fi
|
|
}
|
|
|
|
show_conflicts() {
|
|
cd "$DOTFILES_DIR"
|
|
|
|
local conflicts=$(git diff --name-only --diff-filter=U 2>/dev/null)
|
|
|
|
if [[ -n "$conflicts" ]]; then
|
|
echo -e "${RED}Merge conflicts:${NC}"
|
|
echo "$conflicts" | while read -r file; do
|
|
echo -e " ${RED}✗${NC} $file"
|
|
done
|
|
echo
|
|
echo "Resolve conflicts, then run:"
|
|
echo " git add <file>"
|
|
echo " git commit"
|
|
else
|
|
echo -e "${GREEN}✓${NC} No merge conflicts"
|
|
fi
|
|
}
|
|
|
|
# ============================================================================
|
|
# Main
|
|
# ============================================================================
|
|
|
|
show_help() {
|
|
echo "Usage: dotfiles-sync.sh [COMMAND] [OPTIONS]"
|
|
echo
|
|
echo "Commands:"
|
|
echo " (none) Interactive sync"
|
|
echo " --status Show sync status"
|
|
echo " --push [msg] Push local changes"
|
|
echo " --pull Pull remote changes"
|
|
echo " --watch [sec] Watch and auto-sync (default: 300s)"
|
|
echo " --auto Quick check for shell startup"
|
|
echo " --diff Show local changes diff"
|
|
echo " --log [n] Show sync history"
|
|
echo " --conflicts Show merge conflicts"
|
|
echo " --help Show this help"
|
|
echo
|
|
echo "Aliases:"
|
|
echo " dfs, dfsync Interactive sync"
|
|
echo " dfpush Push local changes"
|
|
echo " dfpull Pull remote changes"
|
|
echo " dfstatus Show sync status"
|
|
echo
|
|
echo "Examples:"
|
|
echo " dfs # Interactive sync"
|
|
echo " dfpush # Push changes"
|
|
echo " dotfiles-sync.sh --push 'Added aliases'"
|
|
echo " dotfiles-sync.sh --watch 60 # Sync every 60 seconds"
|
|
echo
|
|
}
|
|
|
|
main() {
|
|
if [[ ! -d "$DOTFILES_DIR/.git" ]]; then
|
|
echo -e "${RED}✗${NC} Dotfiles directory is not a git repository: $DOTFILES_DIR"
|
|
exit 1
|
|
fi
|
|
|
|
case "${1:-}" in
|
|
--status|-s)
|
|
show_status
|
|
;;
|
|
--push|-p)
|
|
do_push "${2:-}"
|
|
;;
|
|
--pull|-l)
|
|
do_pull
|
|
;;
|
|
--watch|-w)
|
|
do_watch "${2:-300}"
|
|
;;
|
|
--auto|-a)
|
|
do_auto
|
|
;;
|
|
--diff|-d)
|
|
show_diff
|
|
;;
|
|
--log)
|
|
show_log "${2:-20}"
|
|
;;
|
|
--conflicts|-c)
|
|
show_conflicts
|
|
;;
|
|
--help|-h)
|
|
show_help
|
|
;;
|
|
"")
|
|
do_sync
|
|
;;
|
|
*)
|
|
echo "Unknown command: $1"
|
|
show_help
|
|
exit 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
main "$@"
|