| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461 |
- #!/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
- while IFS= read -r group; do
- cat "$script_dir/to_sync.@$group" 2>/dev/null
- done < <(get_groups)
- cat "$script_dir/to_sync.$hostname_id" 2>/dev/null
- } | grep -v '^\s*$' | sort -u
- }
- # 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
- }
- # 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 <subcommand> [options]
- Subcommands:
- sync [--machine|--group <name>] [dot_file]
- Sync dotfiles (optionally add a new dotfile)
- --machine: add to host-specific config ($hostname_id)
- --group <name>: 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.@<group>/ + to_sync.@<group>
- Host overrides: dots.$hostname_id/ + to_sync.$hostname_id
- In-file markers:
- # @host <name> ... # @end (host-specific block)
- # @group <name> ... # @end (group-specific block)
- Files with markers are processed and written (not symlinked).
- For help with each subcommand run:
- $ProgName <subcommand> -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 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 file $source does not exist, skipping"
- continue
- fi
- mkdir -p "$(dirname "$destination")"
- if [[ -f "$source" ]] && file_has_markers "$source"; then
- # File has markers — process and write (not symlink)
- if [[ -L "$destination" ]]; then
- rm "$destination"
- fi
- local comment_prefix
- comment_prefix=$(detect_comment_prefix "$source")
- {
- echo "$comment_prefix DO NOT EDIT - Generated by dotsync from:"
- echo "$comment_prefix $source"
- echo ""
- process_markers "$source"
- } > "$destination.dotsync_tmp"
- # Only update if content changed
- if [[ -f "$destination" ]] && cmp -s "$destination" "$destination.dotsync_tmp"; then
- rm "$destination.dotsync_tmp"
- else
- [[ -e "$destination" ]] && rm "$destination"
- mv "$destination.dotsync_tmp" "$destination"
- chmod --reference="$source" "$destination"
- log_info "Processed: $destination (from $source)"
- fi
- else
- # No markers — symlink as before
- 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
- 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
|