| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629 |
- #!/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
- }
- # Get combined package list (common + groups + host-specific, deduplicated).
- # Unlike dotfiles, package lists are ADDITIVE: every applicable list is unioned
- # rather than overridden, so a host list adds to the common one, never replaces it.
- get_pkg_list() {
- {
- cat "$script_dir/pkglist" 2>/dev/null || true
- while IFS= read -r group; do
- cat "$script_dir/pkglist.@$group" 2>/dev/null || true
- done < <(get_groups)
- cat "$script_dir/pkglist.$hostname_id" 2>/dev/null || true
- } | grep -v '^\s*#' | grep -v '^\s*$' | sort -u || true
- }
- # Explicitly-installed packages on this host.
- # -Qqe (explicit) keeps AUR *dependencies* out of the list; they get pulled in
- # automatically by their parent. The -debug filter drops artifacts makepkg
- # generates when building locally -- they only exist on the machine that built
- # them and mostly aren't installable elsewhere.
- installed_packages() {
- yay -Qqe | grep -v -- '-debug$' | 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 style from the first marker line in a file
- # Returns "block" for /* */ style, or the line-comment prefix (# // ; --)
- detect_comment_style() {
- 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:]]}"}"
- # Check if it's a block comment opener
- if [[ "$prefix" == "/*" ]]; then
- echo "block"
- else
- echo "$prefix"
- fi
- }
- # 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_error "Unclosed @host/@group block in $src"
- return 1
- 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
- packages [install|diff|save] Sync packages (additive: pkglist + pkglist.@<group>
- + pkglist.$hostname_id). Not run by 'sync'.
- 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"
- 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
- for f in bootstrap README.md; do
- [[ -f "$f" ]] && git add "$f" || true
- done
- # Stage host-specific, group-specific, and groups files, plus package lists
- for f in to_sync.* groups.* pkglist pkglist.*; do
- [[ -f "$f" ]] && git add "$f" || true
- 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_style
- comment_style=$(detect_comment_style "$src")
- {
- if [[ "$comment_style" == "block" ]]; then
- echo "/* DO NOT EDIT - Generated by dotsync from: $src"
- echo " Regenerate with: dotsync */"
- else
- echo "$comment_style DO NOT EDIT - Generated by dotsync from:"
- echo "$comment_style $src"
- echo "$comment_style Regenerate with: dotsync"
- fi
- 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")"
- return 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 $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
- mkdir -p "$(dirname "$destination")"
- if ln -s "$source" "$destination"; then
- log_info "Linked: $destination -> $source"
- else
- log_error "Failed to create symlink for $p"
- exit 1
- 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
- }
- sub_packages(){
- local action=${1:-install}
- if [[ "$action" == "-h" || "$action" == "--help" ]]; then
- cat << EOF
- Usage: $ProgName packages [install|diff|save]
- install Install tracked packages missing on this host (default)
- diff Show drift in both directions, change nothing
- save Write this host's explicit packages to pkglist.$hostname_id
- Lists are additive and resolved as:
- pkglist + pkglist.@<group> + pkglist.$hostname_id
- Not run by '$ProgName sync': installing needs sudo and takes time, whereas
- sync is unprivileged and fast. Keep them separate.
- EOF
- return 0
- fi
- local tracked installed missing extra
- tracked=$(get_pkg_list)
- if [[ -z "$tracked" ]]; then
- log_error "No package lists found (pkglist, pkglist.@<group>, pkglist.$hostname_id)"
- log_error "Seed one with: $ProgName packages save"
- return 1
- fi
- installed=$(installed_packages)
- missing=$(comm -23 <(echo "$tracked") <(echo "$installed"))
- extra=$(comm -13 <(echo "$tracked") <(echo "$installed"))
- case $action in
- install)
- if [[ -z "$missing" ]]; then
- log_info "All $(echo "$tracked" | wc -l) tracked packages already installed"
- return 0
- fi
- log_info "Installing $(echo "$missing" | wc -l) missing package(s):"
- echo "$missing" | sed 's/^/ /'
- # Build an argv array and invoke yay directly -- no pipe, no xargs.
- # yay is interactive (cleanBuild, conflict and diff prompts), so it
- # needs to inherit the terminal's stdin. Piping to it steals stdin;
- # xargs is worse, because it points the child's stdin at /dev/null
- # and every prompt immediately reads EOF.
- local -a pkgs
- mapfile -t pkgs <<< "$missing"
- yay -S --needed "${pkgs[@]}"
- ;;
- diff)
- if [[ -n "$missing" ]]; then
- log_warn "Tracked but NOT installed ($(echo "$missing" | wc -l)):"
- echo "$missing" | sed 's/^/ /'
- fi
- if [[ -n "$extra" ]]; then
- log_warn "Installed but NOT tracked ($(echo "$extra" | wc -l)):"
- echo "$extra" | sed 's/^/ /'
- fi
- if [[ -z "$missing" && -z "$extra" ]]; then
- log_info "In sync: $(echo "$tracked" | wc -l) packages"
- fi
- ;;
- save)
- echo "$installed" > "$script_dir/pkglist.$hostname_id"
- log_info "Wrote $(echo "$installed" | wc -l) packages to pkglist.$hostname_id"
- log_warn "Move entries shared with other hosts into pkglist so they are not duplicated"
- ;;
- *)
- log_error "Unknown packages action: '$action'"
- log_error "Run '$ProgName packages --help' for usage."
- return 1
- ;;
- esac
- }
- subcommand=${1:-}
- case $subcommand in
- "" | "-h" | "--help")
- sub_help
- ;;
- sync|list|status|packages)
- 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
|