#!/bin/bash # Dotfiles sync script set -euo pipefail # Configuration hostname_id=$(uname -n) checkout_dir=$HOME/.dofiles script_dir=$checkout_dir/dotfiles dots_dir=$checkout_dir/dotfiles/dots dots_host_dir=$checkout_dir/dotfiles/dots.$hostname_id repo="https://git.capella.pro/capella/dotfiles.git" ProgName=$(basename "$0") # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color # Logging functions log_info() { echo -e "${GREEN}[INFO]${NC} $*"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $*" >&2; } log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } # Get group names for the current host (sorted, one per line) get_groups() { local groups_file="$script_dir/groups.$hostname_id" if [[ -f "$groups_file" ]]; then grep -v '^\s*#' "$groups_file" | grep -v '^\s*$' | sort fi } # Get combined sync list (common + groups + host-specific, deduplicated) get_sync_list() { { cat "$script_dir/to_sync" 2>/dev/null || true while IFS= read -r group; do cat "$script_dir/to_sync.@$group" 2>/dev/null || true done < <(get_groups) cat "$script_dir/to_sync.$hostname_id" 2>/dev/null || true } | grep -v '^\s*$' | sort -u || true } # Resolve source path: common -> groups (alpha) -> host-specific (last wins) resolve_source() { local p="$1" local result="$dots_dir/$p" while IFS= read -r group; do if [[ -e "$script_dir/dots.@$group/$p" ]]; then result="$script_dir/dots.@$group/$p" fi done < <(get_groups) if [[ -e "$dots_host_dir/$p" ]]; then result="$dots_host_dir/$p" fi echo "$result" } # Check if a file contains @host or @group markers file_has_markers() { grep -qE '@(host|group) ' "$1" 2>/dev/null } # Check if any file in a directory tree contains markers dir_has_markers() { grep -rqlE '@(host|group) ' "$1" 2>/dev/null } # Detect the comment prefix from the first marker line in a file detect_comment_prefix() { local src="$1" local marker_line marker_line=$(grep -m1 -E '@(host|group) ' "$src") # Extract everything before @host or @group local prefix="${marker_line%%@*}" # Trim trailing whitespace prefix="${prefix%"${prefix##*[![:space:]]}"}" echo "$prefix" } # Process a file with @host/@group markers, outputting only matching sections process_markers() { local src="$1" local in_block=0 local include_block=0 # Pre-load groups into an associative array for fast lookup declare -A host_groups while IFS= read -r g; do host_groups["$g"]=1 done < <(get_groups) while IFS= read -r line || [[ -n "$line" ]]; do if [[ $in_block -eq 0 ]]; then if [[ "$line" =~ @host[[:space:]]+([^[:space:]]+) ]]; then in_block=1 if [[ "${BASH_REMATCH[1]}" == "$hostname_id" ]]; then include_block=1 else include_block=0 fi elif [[ "$line" =~ @group[[:space:]]+([^[:space:]]+) ]]; then in_block=1 if [[ -v "host_groups[${BASH_REMATCH[1]}]" ]]; then include_block=1 else include_block=0 fi elif [[ "$line" =~ @end ]]; then # Stray @end outside a block, skip it continue else printf '%s\n' "$line" fi else if [[ "$line" =~ @end ]]; then in_block=0 include_block=0 elif [[ $include_block -eq 1 ]]; then printf '%s\n' "$line" fi fi done < "$src" if [[ $in_block -eq 1 ]]; then log_warn "Unclosed @host/@group block in $src" fi } # Ensure we cleanup on exit trap 'cd "$HOME"' EXIT # Create checkout directory if needed mkdir -p "$checkout_dir" sub_help(){ local groups groups=$(get_groups | tr '\n' ', ' | sed 's/,$//') cat << EOF Usage: $ProgName [options] Subcommands: sync [--machine|--group ] [dot_file] Sync dotfiles (optionally add a new dotfile) --machine: add to host-specific config ($hostname_id) --group : add to group-specific config list List currently synced dotfiles status Show git status of dotfiles repo Host: $hostname_id Groups: ${groups:-none} Resolution order: common -> groups (alpha) -> host-specific Common dotfiles: dots/ + to_sync Group dotfiles: dots.@/ + to_sync.@ Host overrides: dots.$hostname_id/ + to_sync.$hostname_id In-file markers: # @host ... # @end (host-specific block) # @group ... # @end (group-specific block) Files with markers are processed and written (not symlinked). For help with each subcommand run: $ProgName -h|--help EOF } sub_sync(){ # Clone repo if it doesn't exist if [[ ! -d "$checkout_dir/dotfiles/.git" ]]; then log_info "Cloning dotfiles repository..." cd "$checkout_dir" if ! git clone "$repo"; then log_error "Failed to clone repository" exit 1 fi cd "$script_dir" git submodule init git submodule update fi # Navigate to script directory cd "$script_dir" # Get current branch current_branch=$(git branch --show-current) log_info "Pulling latest changes from $current_branch..." git pull origin "$current_branch" || log_warn "Git pull failed, continuing anyway" git submodule update --init --recursive # Parse flags for adding new dotfiles local machine_specific=0 local group_target="" if [[ "${1:-}" == "--machine" ]]; then machine_specific=1 shift elif [[ "${1:-}" == "--group" ]]; then group_target="${2:-}" if [[ -z "$group_target" ]]; then log_error "--group requires a group name" exit 1 fi shift 2 fi # Add new dotfile if provided if [ -n "${1:-}" ]; then local file_to_add="$1" # Validate that file exists if [[ ! -e "$HOME/$file_to_add" ]]; then log_error "File $HOME/$file_to_add does not exist" exit 1 fi log_info "Adding new dotfile: $file_to_add" real_path=$(realpath "$HOME/$file_to_add") to_copy=${real_path#"$HOME/"} if [[ $machine_specific -eq 1 ]]; then target_dots_dir="$dots_host_dir" sync_file="$script_dir/to_sync.$hostname_id" log_info "Adding as host-specific ($hostname_id)" elif [[ -n "$group_target" ]]; then target_dots_dir="$script_dir/dots.@$group_target" sync_file="$script_dir/to_sync.@$group_target" log_info "Adding as group-specific (@$group_target)" else target_dots_dir="$dots_dir" sync_file="$script_dir/to_sync" fi # Create parent directory structure mkdir -p "$(dirname "$target_dots_dir/$to_copy")" # Copy to dots directory if cp -R "$real_path" "$target_dots_dir/$to_copy"; then log_info "Copied $file_to_add to $target_dots_dir/$to_copy" else log_error "Failed to copy $file_to_add" exit 1 fi # Add to sync file if not already there if ! grep -qxF "$to_copy" "$sync_file" 2>/dev/null; then echo "$to_copy" >> "$sync_file" log_info "Added $to_copy to $(basename "$sync_file")" else log_info "$to_copy already in $(basename "$sync_file")" fi # Remove original and create symlink rm -rf "$real_path" ln -s "$target_dots_dir/$to_copy" "$real_path" log_info "Created symlink: $real_path -> $target_dots_dir/$to_copy" fi # Stage tracked files from all source directories log_info "Staging changes..." get_sync_list | while IFS= read -r p; do if [[ -e "$dots_dir/$p" ]]; then git add "$dots_dir/$p" fi while IFS= read -r group; do if [[ -e "$script_dir/dots.@$group/$p" ]]; then git add "$script_dir/dots.@$group/$p" fi done < <(get_groups) if [[ -e "$dots_host_dir/$p" ]]; then git add "$dots_host_dir/$p" fi done git add start to_sync # Stage host-specific, group-specific, and groups files for f in to_sync.* groups.*; do [[ -f "$f" ]] && git add "$f" done for d in dots.*/; do [[ -d "$d" ]] && git add "$d" done # Commit and push if there are changes if git diff --cached --quiet; then log_info "No changes to commit" else log_info "Committing changes..." if git commit -m "Sync: $(date '+%Y-%m-%d %H:%M:%S')"; then log_info "Pushing to remote..." git remote add origin "$repo" 2>/dev/null || true if git push -u origin "$current_branch"; then log_info "Successfully pushed changes" else log_error "Failed to push changes" exit 1 fi else log_error "Failed to commit changes" exit 1 fi fi # Deploy a single file: process markers or symlink deploy_file() { local src="$1" local dest="$2" mkdir -p "$(dirname "$dest")" if file_has_markers "$src"; then # File has markers — process and write (not symlink) if [[ -L "$dest" ]]; then rm "$dest" fi local comment_prefix comment_prefix=$(detect_comment_prefix "$src") { echo "$comment_prefix DO NOT EDIT - Generated by dotsync from:" echo "$comment_prefix $src" echo "" process_markers "$src" } > "$dest.dotsync_tmp" # Only update if content changed if [[ -f "$dest" ]] && cmp -s "$dest" "$dest.dotsync_tmp"; then rm "$dest.dotsync_tmp" else [[ -e "$dest" ]] && rm "$dest" mv "$dest.dotsync_tmp" "$dest" chmod --reference="$src" "$dest" log_info "Processed: $dest (from $src)" fi else # No markers — symlink if [[ -L "$dest" ]] && [[ "$(readlink "$dest")" == "$src" ]]; then return # Already correctly linked fi if [[ -e "$dest" ]] || [[ -L "$dest" ]]; then log_warn "Removing existing $dest" rm -rf "$dest" fi if ln -s "$src" "$dest"; then log_info "Linked: $dest -> $src" else log_error "Failed to create symlink for $(basename "$dest")" fi fi } # Deploy dotfiles (symlink or process markers) log_info "Deploying dotfiles for $hostname_id..." get_sync_list | while IFS= read -r p; do local source source=$(resolve_source "$p") local destination="$HOME/$p" if [[ ! -e "$source" ]]; then log_warn "Source $source does not exist, skipping" continue fi if [[ -d "$source" ]]; then if dir_has_markers "$source"; then # Directory contains markers — expand into per-file operations # Remove directory-level symlink if present if [[ -L "$destination" ]]; then log_warn "Replacing directory symlink $destination with expanded files" rm "$destination" fi mkdir -p "$destination" # Walk all files in the source directory while IFS= read -r src_file; do local rel="${src_file#"$source/"}" deploy_file "$src_file" "$destination/$rel" done < <(find "$source" -type f | sort) else # No markers in directory — symlink the whole directory if [[ -L "$destination" ]] && [[ "$(readlink "$destination")" == "$source" ]]; then continue # Already correctly linked fi if [[ -e "$destination" ]] || [[ -L "$destination" ]]; then log_warn "Removing existing $destination" rm -rf "$destination" fi if ln -s "$source" "$destination"; then log_info "Linked: $destination -> $source" else log_error "Failed to create symlink for $p" fi fi else # Single file entry deploy_file "$source" "$destination" fi done log_info "Sync complete!" } sub_list(){ echo "Host: $hostname_id" local groups groups=$(get_groups | tr '\n' ', ' | sed 's/,$//') if [[ -n "$groups" ]]; then echo "Groups: $groups" fi echo "" if [[ -f "$script_dir/to_sync" ]]; then echo "Common dotfiles (to_sync):" while IFS= read -r p; do [[ -z "$p" ]] && continue local source source=$(resolve_source "$p") local tag="" if [[ "$source" == "$dots_host_dir/"* ]]; then tag=" [override: $hostname_id]" elif [[ "$source" == *"/dots.@"* ]]; then local gname gname=$(echo "$source" | sed 's|.*dots\.@\([^/]*\)/.*|\1|') tag=" [override: @$gname]" fi local marker_tag="" if [[ -f "$source" ]] && file_has_markers "$source"; then marker_tag=" [processed]" fi if [[ -L "$HOME/$p" ]] || [[ -f "$HOME/$p" ]] || [[ -d "$HOME/$p" ]]; then echo " + $p$tag$marker_tag" else echo " - $p (not deployed)$tag$marker_tag" fi done < "$script_dir/to_sync" fi # Group-specific dotfiles while IFS= read -r group; do local gsync="$script_dir/to_sync.@$group" if [[ -f "$gsync" ]] && [[ -s "$gsync" ]]; then echo "" echo "Group dotfiles (to_sync.@$group):" while IFS= read -r p; do [[ -z "$p" ]] && continue if [[ -L "$HOME/$p" ]] || [[ -f "$HOME/$p" ]] || [[ -d "$HOME/$p" ]]; then echo " + $p" else echo " - $p (not deployed)" fi done < "$gsync" fi done < <(get_groups) # Host-specific dotfiles if [[ -f "$script_dir/to_sync.$hostname_id" ]] && [[ -s "$script_dir/to_sync.$hostname_id" ]]; then echo "" echo "Host-specific dotfiles (to_sync.$hostname_id):" while IFS= read -r p; do [[ -z "$p" ]] && continue if [[ -L "$HOME/$p" ]] || [[ -f "$HOME/$p" ]] || [[ -d "$HOME/$p" ]]; then echo " + $p" else echo " - $p (not deployed)" fi done < "$script_dir/to_sync.$hostname_id" fi } sub_status(){ if [[ ! -d "$script_dir/.git" ]]; then log_error "Dotfiles repository not found" exit 1 fi cd "$script_dir" log_info "Host: $hostname_id" log_info "Git status:" git status } subcommand=${1:-} case $subcommand in "" | "-h" | "--help") sub_help ;; sync|list|status) shift "sub_${subcommand}" "$@" ;; *) log_error "'$subcommand' is not a known subcommand." echo " Run '$ProgName --help' for a list of known subcommands." >&2 exit 1 ;; esac