#!/bin/bash # Enhanced dotfiles sync utility # Purpose: Sync dotfiles with repository while supporting blacklists and flexible sync configurations set -euo pipefail # Exit on error, undefined vars, pipe failures # Configuration readonly REPO_DIR="${HOME}/repos/dotfiles" readonly CONFIG_DIR="${HOME}/.config" readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Global blacklist patterns (regex) # These patterns apply to ALL sync operations declare -a GLOBAL_BLACKLIST_PATTERNS=( "\.git(/|$)" # Git directories "\.DS_Store$" # macOS metadata "\.swp$" # Vim swap files "\.tmp$" # Temporary files "\.bak$" # Backup files "~$" # Temporary files ending with ~ ) # Sync configuration - enhanced approach # Format: "source_path:target_path:sync_type:local_blacklist_patterns" # sync_type: "dir" for directories, "file" for files # local_blacklist_patterns: comma-separated regex patterns (optional) declare -a SYNC_CONFIGS=( # Config directories with specific blacklist patterns "${CONFIG_DIR}/hypr:${REPO_DIR}/user-home/.config/hypr:dir:logs?(/|$),\.log$" "${CONFIG_DIR}/kitty:${REPO_DIR}/user-home/.config/kitty:dir:" "${CONFIG_DIR}/systemd:${REPO_DIR}/user-home/.config/systemd:dir:buildkit.*,containerd.*" "${CONFIG_DIR}/gtk-3.0:${REPO_DIR}/user-home/.config/gtk-3.0:dir:bookmarks" "${CONFIG_DIR}/gtk-4.0:${REPO_DIR}/user-home/.config/gtk-4.0:dir:" "${CONFIG_DIR}/xsettingsd:${REPO_DIR}/user-home/.config/xsettingsd:dir:" "${CONFIG_DIR}/uwsm:${REPO_DIR}/user-home/.config/uwsm:dir:" "${CONFIG_DIR}/waybar:${REPO_DIR}/user-home/.config/waybar:dir:" "${CONFIG_DIR}/wlogout:${REPO_DIR}/user-home/.config/wlogout:dir:" "${CONFIG_DIR}/nvim:${REPO_DIR}/user-home/.config/nvim:dir:session(/|$),\.session$,swap(/|$),undo(/|$),view(/|$)" # Individual files (blacklist patterns not applicable to single files) "${HOME}/.zshrc:${REPO_DIR}/user-home/.zshrc:file:" "${HOME}/.gtkrc-2.0:${REPO_DIR}/user-home/.gtkrc-2.0:file:" "${HOME}/.tmux.conf:${REPO_DIR}/user-home/.tmux.conf:file:" "${HOME}/.oh-my-zsh/custom/aliases.zsh:${REPO_DIR}/user-home/aliases.zsh:file:" "${HOME}/wp.png:${REPO_DIR}/wp.png:file:" # System directories with specific patterns "/etc/greetd:${REPO_DIR}/greetd:dir:cache(/|$),run(/|$),pid$" ) # Colors for output - only use if terminal supports colors if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && tput colors >/dev/null 2>&1 && [[ $(tput colors) -ge 8 ]]; then readonly RED='\033[0;31m' readonly GREEN='\033[0;32m' readonly YELLOW='\033[1;33m' readonly BLUE='\033[0;34m' readonly PURPLE='\033[0;35m' readonly CYAN='\033[0;36m' readonly WHITE='\033[1;37m' readonly BOLD='\033[1m' readonly NC='\033[0m' # No Color else # No color support readonly RED='' readonly GREEN='' readonly YELLOW='' readonly BLUE='' readonly PURPLE='' readonly CYAN='' readonly WHITE='' readonly BOLD='' readonly NC='' fi # Logging functions log_info() { echo -e "${BLUE}[INFO]${NC} $*" } log_success() { echo -e "${GREEN}[SUCCESS]${NC} $*" } log_warning() { echo -e "${YELLOW}[WARNING]${NC} $*" } log_error() { echo -e "${RED}[ERROR]${NC} $*" } # Check if a file/path should be blacklisted is_blacklisted() { local path="$1" local local_patterns="$2" # Comma-separated local patterns local relative_path="${path#*/}" # Remove leading path components for matching # Check global blacklist patterns for pattern in "${GLOBAL_BLACKLIST_PATTERNS[@]}"; do if [[ "$relative_path" =~ $pattern ]]; then return 0 # Is blacklisted fi done # Check local blacklist patterns if provided if [[ -n "$local_patterns" ]]; then IFS=',' read -ra local_pattern_array <<< "$local_patterns" for pattern in "${local_pattern_array[@]}"; do # Trim whitespace pattern=$(echo "$pattern" | xargs) if [[ -n "$pattern" && "$relative_path" =~ $pattern ]]; then return 0 # Is blacklisted fi done fi return 1 # Not blacklisted } # Enhanced copy function with blacklist support safe_copy() { local src="$1" local dest="$2" local is_dir="$3" local local_patterns="$4" if [[ "$is_dir" == "true" ]]; then # For directories, use rsync with exclude patterns local rsync_excludes=() # Add global blacklist patterns for pattern in "${GLOBAL_BLACKLIST_PATTERNS[@]}"; do rsync_excludes+=("--exclude=$pattern") done # Add local blacklist patterns if provided if [[ -n "$local_patterns" ]]; then IFS=',' read -ra local_pattern_array <<< "$local_patterns" for pattern in "${local_pattern_array[@]}"; do # Trim whitespace pattern=$(echo "$pattern" | xargs) if [[ -n "$pattern" ]]; then rsync_excludes+=("--exclude=$pattern") fi done fi mkdir -p "$(dirname "$dest")" rsync -av --delete "${rsync_excludes[@]}" "$src/" "$dest/" else # For files, check blacklist before copying if is_blacklisted "$src" "$local_patterns"; then log_warning "Skipping blacklisted file: $src" return 0 fi mkdir -p "$(dirname "$dest")" cp "$src" "$dest" fi } # Enhanced dry-run function to show what files would be synced show_dry_run_preview() { local config="$1" local temp_repo_dir="${2:-}" # Use empty string as default if not provided local source_path target_path sync_type local_patterns IFS=':' read -r source_path target_path sync_type local_patterns <<< "$config" if [[ ! -e "$source_path" ]]; then if [[ -z "$temp_repo_dir" ]]; then printf " ${YELLOW}⚠️ Source does not exist:${NC} %s\n" "$source_path" fi return 0 fi # If temp_repo_dir is provided, actually sync to temp directory (for summary) if [[ -n "$temp_repo_dir" ]]; then # Adjust target path to use temp directory local temp_target_path="${target_path/$REPO_DIR/$temp_repo_dir}" # Remove target before copying if [[ -e "$temp_target_path" ]]; then rm -rf "$temp_target_path" fi case "$sync_type" in "dir") if [[ -d "$source_path" ]]; then safe_copy "$source_path" "$temp_target_path" true "$local_patterns" fi ;; "file") if [[ -f "$source_path" ]]; then safe_copy "$source_path" "$temp_target_path" false "$local_patterns" fi ;; esac return 0 fi # Original preview logic (when temp_repo_dir is not provided) if [[ -n "$local_patterns" ]]; then printf "${CYAN}📁 %s${NC} -> ${PURPLE}%s${NC} ${WHITE}(%s)${NC} ${YELLOW}[local patterns: %s]${NC}\n" \ "$source_path" "$target_path" "$sync_type" "$local_patterns" else printf "${CYAN}📁 %s${NC} -> ${PURPLE}%s${NC} ${WHITE}(%s)${NC}\n" \ "$source_path" "$target_path" "$sync_type" fi if [[ "$sync_type" == "file" ]]; then if is_blacklisted "$source_path" "$local_patterns"; then printf " ${RED}❌ Would skip (blacklisted):${NC} %s\n" "$(basename "$source_path")" else printf " ${GREEN}✅ Would sync:${NC} %s\n" "$(basename "$source_path")" fi elif [[ "$sync_type" == "dir" && -d "$source_path" ]]; then # Simulate what rsync would do local temp_dir=$(mktemp -d) local rsync_excludes=() # Add global blacklist patterns for pattern in "${GLOBAL_BLACKLIST_PATTERNS[@]}"; do rsync_excludes+=("--exclude=$pattern") done # Add local blacklist patterns if provided if [[ -n "$local_patterns" ]]; then IFS=',' read -ra local_pattern_array <<< "$local_patterns" for pattern in "${local_pattern_array[@]}"; do pattern=$(echo "$pattern" | xargs) if [[ -n "$pattern" ]]; then rsync_excludes+=("--exclude=$pattern") fi done fi # Use rsync with --dry-run to see what would be copied printf " ${WHITE}Files that would be synced:${NC}\n" rsync -av --dry-run "${rsync_excludes[@]}" "$source_path/" "$temp_dir/" 2>/dev/null | \ grep -E '^[^d]' | \ head -20 | \ while read -r line; do if [[ "$line" =~ ^[^d] ]]; then printf " ${GREEN}✅${NC} %s\n" "$line" fi done # Show excluded files if any printf " ${WHITE}Files that would be excluded:${NC}\n" local found_excluded=false while IFS= read -r -d '' file; do local relative_file="${file#$source_path/}" if is_blacklisted "$file" "$local_patterns"; then printf " ${RED}❌${NC} %s\n" "$relative_file" found_excluded=true fi done < <(find "$source_path" -type f -print0 2>/dev/null | head -20) if [[ "$found_excluded" == false ]]; then printf " ${YELLOW}(no files would be excluded with current patterns)${NC}\n" fi rm -rf "$temp_dir" fi printf "\n" } # Generate comprehensive dry-run summary using git diff show_dry_run_summary() { log_info "Generating comprehensive change summary..." # Validate that we have git and the repo exists if [[ ! -d "$REPO_DIR" ]]; then log_error "Repository directory does not exist: $REPO_DIR" return 1 fi if ! command -v git &> /dev/null; then log_error "git is required for summary generation" return 1 fi # Create temporary directory and copy repository local temp_base_dir=$(mktemp -d) local temp_repo_dir="$temp_base_dir/dotfiles" log_info "Creating temporary repository copy..." cp -r "$REPO_DIR" "$temp_repo_dir" || { log_error "Failed to copy repository to temporary directory" rm -rf "$temp_base_dir" return 1 } # Perform sync operations to temporary directory - just like real sync but to temp location log_info "Simulating sync operations..." local simulation_errors=0 for config in "${SYNC_CONFIGS[@]}"; do local source_path target_path sync_type local_patterns IFS=':' read -r source_path target_path sync_type local_patterns <<< "$config" # Skip if source doesn't exist if [[ ! -e "$source_path" ]]; then continue fi # Replace original repo path with temp repo path local temp_target_path="${target_path/$REPO_DIR/$temp_repo_dir}" # Remove target before copying (like real sync) if [[ -e "$temp_target_path" ]]; then rm -rf "$temp_target_path" fi # Perform actual sync operation to temp location case "$sync_type" in "dir") if [[ -d "$source_path" ]]; then if ! safe_copy "$source_path" "$temp_target_path" true "$local_patterns"; then ((simulation_errors++)) fi fi ;; "file") if [[ -f "$source_path" ]]; then if ! safe_copy "$source_path" "$temp_target_path" false "$local_patterns"; then ((simulation_errors++)) fi fi ;; esac done if [[ $simulation_errors -gt 0 ]]; then log_warning "$simulation_errors simulation operation(s) had issues" fi # Change to temp repo directory and show git status cd "$temp_repo_dir" || { log_error "Failed to change to temporary directory" rm -rf "$temp_base_dir" return 1 } # Check if it's a git repository if [[ ! -d ".git" ]]; then log_error "Temporary copy is not a git repository" rm -rf "$temp_base_dir" return 1 fi printf "\n${BOLD}${CYAN}═══════════════════════════════════════════════════════════════${NC}\n" printf "${BOLD}${WHITE} CHANGE SUMMARY ${NC}\n" printf "${BOLD}${CYAN}═══════════════════════════════════════════════════════════════${NC}\n\n" # Show git status local git_status=$(git status --porcelain 2>/dev/null) if [[ -z "$git_status" ]]; then printf "${GREEN}✅ No changes detected${NC} - Repository is already up to date!\n\n" else printf "${BOLD}Git Status Output:${NC}\n" git status --short printf "\n" # Show some diff preview for modified files printf "${BOLD}Content Changes Preview:${NC}\n" local has_changes=false while IFS= read -r line; do local status="${line:0:2}" local filename="${line:3}" if [[ "$status" =~ M ]]; then has_changes=true printf "\n${CYAN}📄 %s:${NC}\n" "$filename" git diff --no-color "$filename" 2>/dev/null | head -10 | while IFS= read -r diff_line; do if [[ "$diff_line" =~ ^- ]]; then printf " ${RED}%s${NC}\n" "$diff_line" elif [[ "$diff_line" =~ ^+ ]]; then printf " ${GREEN}%s${NC}\n" "$diff_line" elif [[ "$diff_line" =~ ^@@ ]]; then printf " ${BLUE}%s${NC}\n" "$diff_line" fi done elif [[ "$status" =~ ^\?\? ]]; then has_changes=true printf "\n${GREEN}📄 %s (new file):${NC}\n" "$filename" printf " ${GREEN}+ This is a new file that will be added${NC}\n" fi done <<< "$git_status" if [[ "$has_changes" == false ]]; then printf " ${YELLOW}(Only deletions detected - no content preview available)${NC}\n" fi fi printf "\n${BOLD}${CYAN}═══════════════════════════════════════════════════════════════${NC}\n\n" # Cleanup - go back to original directory and remove temp cd "$REPO_DIR" || cd / rm -rf "$temp_base_dir" if [[ -n "$git_status" ]]; then printf "${BOLD}To apply these changes, run:${NC} %s\n" "$(basename "$0")" printf "${BOLD}To skip git operations:${NC} %s --no-git\n\n" "$(basename "$0")" fi } # Sync function with improved error handling sync_item() { local config="$1" local source_path target_path sync_type local_patterns IFS=':' read -r source_path target_path sync_type local_patterns <<< "$config" if [[ ! -e "$source_path" ]]; then log_warning "Source path does not exist: $source_path" return 0 fi if [[ -n "$local_patterns" ]]; then log_info "Syncing $source_path -> $target_path (with local patterns: $local_patterns)" else log_info "Syncing $source_path -> $target_path" fi # Remove target before copying if [[ -e "$target_path" ]]; then rm -rf "$target_path" fi case "$sync_type" in "dir") if [[ ! -d "$source_path" ]]; then log_error "$source_path is not a directory" return 1 fi safe_copy "$source_path" "$target_path" true "$local_patterns" ;; "file") if [[ ! -f "$source_path" ]]; then log_error "$source_path is not a file" return 1 fi safe_copy "$source_path" "$target_path" false "$local_patterns" ;; *) log_error "Unknown sync type: $sync_type" return 1 ;; esac log_success "Synced $source_path" } # Enhanced git operations with better error handling gitops() { log_info "Checking git repository status..." if [[ ! -d "$REPO_DIR" ]]; then log_error "Repository directory does not exist: $REPO_DIR" return 1 fi cd "$REPO_DIR" || { log_error "Failed to change to repository directory" return 1 } if [[ ! -d ".git" ]]; then log_error "Not a git repository: $REPO_DIR" return 1 fi if [[ -z "$(git status --porcelain)" ]]; then log_info "No changes detected in repository" return 0 fi log_info "Changes detected in repository:" git status --short echo read -p "Continue with committing? (Y/n) >> " confirm if [[ "$confirm" =~ ^[nN]([oO])?$ ]]; then log_info "Commit cancelled by user" return 0 fi read -p "Commit message (leave empty for automatic) >> " commit_message if [[ -z "$commit_message" ]]; then commit_message="auto sync $(date '+%Y-%m-%d %H:%M')" fi git add -A git commit -m "$commit_message" log_success "Changes committed: $commit_message" echo read -p "Continue with pushing? (Y/n) >> " confirm if [[ "$confirm" =~ ^[nN]([oO])?$ ]]; then log_info "Push cancelled by user" return 0 fi git push origin main log_success "Changes pushed to remote repository" } # Validation function validate_environment() { local errors=0 if [[ ! -d "$REPO_DIR" ]]; then log_error "Repository directory does not exist: $REPO_DIR" ((errors++)) fi if ! command -v rsync &> /dev/null; then log_error "rsync is required but not installed" ((errors++)) fi if ! command -v git &> /dev/null; then log_error "git is required but not installed" ((errors++)) fi return $errors } # Show colorized usage information show_usage() { printf "${BOLD}${CYAN}Enhanced Dotfiles Sync Utility${NC}\n\n" printf "${BOLD}USAGE:${NC}\n" printf " ${WHITE}%s${NC} ${YELLOW}[OPTIONS]${NC}\n\n" "$(basename "$0")" printf "${BOLD}OPTIONS:${NC}\n" printf " ${GREEN}-h, --help${NC} Show this help message\n" printf " ${GREEN}-n, --dry-run${NC} Show what would be synced without actually doing it\n" printf " ${GREEN}--no-git${NC} Skip git operations\n" printf " ${GREEN}--list-blacklist${NC} Show current blacklist patterns\n\n" printf "${BOLD}EXAMPLES:${NC}\n" printf " ${WHITE}%s${NC} ${YELLOW}# Normal sync with git operations${NC}\n" "$(basename "$0")" printf " ${WHITE}%s${NC} ${GREEN}--dry-run${NC} ${YELLOW}# Preview what would be synced (detailed)${NC}\n" "$(basename "$0")" printf " ${WHITE}%s${NC} ${GREEN}--no-git${NC} ${YELLOW}# Sync files but skip git operations${NC}\n" "$(basename "$0")" printf " ${WHITE}%s${NC} ${GREEN}--list-blacklist${NC} ${YELLOW}# Show current blacklist patterns${NC}\n\n" "$(basename "$0")" printf "${BOLD}DESCRIPTION:${NC}\n" printf " This script syncs dotfiles between your local system and a git repository.\n" printf " It supports flexible sync configurations with blacklist patterns to exclude\n" printf " unwanted files like logs, temporary files, and system-specific data.\n\n" printf "${BOLD}CONFIGURATION:${NC}\n" printf " ${CYAN}Repository Directory:${NC} ${PURPLE}%s${NC}\n" "$REPO_DIR" printf " ${CYAN}Config Directory:${NC} ${PURPLE}%s${NC}\n\n" "$CONFIG_DIR" printf "${BOLD}FEATURES:${NC}\n" printf " • ${GREEN}✓${NC} Flexible sync configurations for files and directories\n" printf " • ${GREEN}✓${NC} Global and per-directory blacklist patterns\n" printf " • ${GREEN}✓${NC} Automatic git operations with user confirmation\n" printf " • ${GREEN}✓${NC} Dry-run mode to preview changes\n" printf " • ${GREEN}✓${NC} Colored output for better readability\n" printf " • ${GREEN}✓${NC} Comprehensive error handling and validation\n\n" printf "For more information, run with ${GREEN}--list-blacklist${NC} to see current patterns.\n" } # Main function main() { local dry_run=false local no_git=false # Parse command line arguments while [[ $# -gt 0 ]]; do case $1 in -h|--help) show_usage exit 0 ;; -n|--dry-run) dry_run=true shift ;; --no-git) no_git=true shift ;; --list-blacklist) printf "${BOLD}${CYAN}Global blacklist patterns:${NC}\n" printf ' %s\n' "${GLOBAL_BLACKLIST_PATTERNS[@]}" printf "\n" printf "${BOLD}${CYAN}Per-directory blacklist patterns:${NC}\n" for config in "${SYNC_CONFIGS[@]}"; do local source_path target_path sync_type local_patterns IFS=':' read -r source_path target_path sync_type local_patterns <<< "$config" if [[ -n "$local_patterns" && "$sync_type" == "dir" ]]; then printf " ${YELLOW}%s:${NC} %s\n" "$source_path" "$local_patterns" fi done exit 0 ;; *) log_error "Unknown option: $1" echo show_usage exit 1 ;; esac done log_info "Starting dotfiles sync..." # Validate environment if ! validate_environment; then log_error "Environment validation failed" exit 1 fi if [[ "$dry_run" == true ]]; then printf "${BOLD}${YELLOW}DRY RUN MODE${NC} - No files will be modified\n\n" # Show detailed preview first printf "${BOLD}${CYAN}Detailed Preview:${NC}\n\n" for config in "${SYNC_CONFIGS[@]}"; do show_dry_run_preview "$config" done # Generate comprehensive summary show_dry_run_summary exit 0 fi # Perform sync operations local sync_errors=0 for config in "${SYNC_CONFIGS[@]}"; do if ! sync_item "$config"; then ((sync_errors++)) fi done if [[ $sync_errors -gt 0 ]]; then log_warning "$sync_errors sync operation(s) failed" else log_success "All sync operations completed successfully" fi # Git operations if [[ "$no_git" != true ]]; then if ! gitops; then log_error "Git operations failed" exit 1 fi else log_info "Skipping git operations (--no-git specified)" fi log_success "Dotfiles sync completed!" } # Run main function with all arguments main "$@"